From a13447a3591170660599e0962c1d9a6d0ff73c52 Mon Sep 17 00:00:00 2001
From: "claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)"
Date: Fri, 7 Aug 2026 18:18:36 +0000
Subject: [PATCH] Draw tangled issues the way the app draws userinput feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A project whose remote is on tangled gets a Tangled item in the rail, beside
Userinput and under the same project, and the issues live on a page of their own
rather than in a muted section at the foot of the goal list. They are drawn as
the app draws work: one unit row per open issue — the filer's disc where a state
circle goes, the forge's own word for its state as a flat chip, the title in the
cell every other row puts its name in — with the whole body, verbatim, in the
drawer under it. The two surfaces are the same kind of thing (somebody else's
words, fetched from somebody else's service, entering this space only as a goal a
member signed), so they are now the same surface; a reader answering "what is
this, who said it, has anybody taken it up" answers it the same way in both.
Nothing about the safety argument moves. Importing still writes nothing: it opens
the ordinary goal composer seeded with the issue's words, with the sentence that
says what creating it means, and the goal a member signs is the only thing that
ever reaches a turn. The body is still quoted in `.brief` and put through no
markdown pass, so a hostile issue renders as its own source.
What does move is when the browser talks to tangled. The per-profile "look for
issues" preference is gone, because a page you navigate to IS the ask — the
bargain the Userinput page already makes with a stranger's board. A rail item
costs Bobbin nothing and the page behind it is the first thing that asks it
anything, so no view of the space queries an appview on sight. Once open it looks
once per remote and not once per sync tick: the space object is republished every
ten seconds whether or not a record moved, so the discovery effect reads only
string deriveds, and this space's own goals — which make a look cheaper by
skipping issues already taken up — are read under `untrack`, since a cost saving
must never become a reason to re-read a stranger's repository.
`importedIssues` now keeps every goal that named an issue rather than the first,
which is `goalsByOrigin`'s shape and lets `issueRows` join a look to the fold at
the point of drawing exactly as `feedbackRows` does. So an issue imported from
this page keeps its row, badges itself, and links the goal, instead of vanishing.
Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)
---
docs/design.md | 7 +-
packages/ui/README.md | 34 +-
packages/ui/src/app.css | 15 +-
.../src/lib/components/ProjectIssues.svelte | 238 -------------
.../components/ProjectIssues.svelte.test.ts | 216 ------------
packages/ui/src/lib/components/Rail.svelte | 9 +
packages/ui/src/lib/issues.test.ts | 19 +-
packages/ui/src/lib/issues.ts | 69 ++--
packages/ui/src/lib/space.ts | 10 +
.../ui/src/routes/p/[project]/+page.svelte | 24 +-
.../routes/p/[project]/tangled/+page.svelte | 316 ++++++++++++++++++
.../tangled/tangled-page.svelte.test.ts | 303 +++++++++++++++++
packages/ui/test/thread-grid.test.mjs | 15 +-
13 files changed, 735 insertions(+), 540 deletions(-)
delete mode 100644 packages/ui/src/lib/components/ProjectIssues.svelte
delete mode 100644 packages/ui/src/lib/components/ProjectIssues.svelte.test.ts
create mode 100644 packages/ui/src/routes/p/[project]/tangled/+page.svelte
create mode 100644 packages/ui/src/routes/p/[project]/tangled/tangled-page.svelte.test.ts
diff --git a/docs/design.md b/docs/design.md
index 0b5cdb5..31dabc9 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -315,9 +315,10 @@ cannot claim this value, and a later reroute cannot rewrite an already-signed re
field, with the sentence saying what creating it means. And `source` — the pointer back to the issue
— is deliberately **not** in the turn bundle: it exists so the space can tell which issues it has
already taken up, and an agent handed the at-uri could fetch the live issue and read comments and
- edits nobody reviewed. Discovery is view-time and opt-in for the same reason guest-comment discovery
- is: the appview that lists issues is a third party, its answer is not part of the fold, and two
- materializers agree whether or not it answered.
+ edits nobody reviewed. Discovery is view-time and asked for, for the same reason guest-comment
+ discovery is: the appview that lists issues is a third party, its answer is not part of the fold,
+ two materializers agree whether or not it answered, and the ask is a reader opening the section's
+ own page rather than any view of the space querying it on sight.
## 14. Alternatives considered
diff --git a/packages/ui/README.md b/packages/ui/README.md
index b02839c..9702a4f 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -40,7 +40,8 @@ if a `node:*` import creeps back onto that path.
| `src/lib/guests.ts` | Comments from people who are not members: the Constellation backlink query, the re-validation that makes the index a hint rather than an authority, and the rows the Community section draws. The only module that reads a non-member's repo, and nothing it returns enters the fold. |
| `src/lib/userinput.ts` | The Userinput section: defensive parsers for a foreign feedback board, the trust fold over its grants and statuses, and the editable import draft. Authority is honoured only against the version a strongref pins; presentation resolves by URI, and latest-wins is decided by `core`'s comparators rather than the reader's locale. `discoverUserinput` reads only the board — the space's own goals are joined onto the result by `feedbackRows` at the point of drawing, so the network fan-out is a function of the board's address and not of the sync tick. Browser-only, and a member pressing Create is the only way any of it reaches the fold. |
| `src/routes/p/[project]/userinput/` | The section itself. A piece of feedback is drawn with the unit row's own furniture — disc, tail, title, drawer — because it is answering the same question every other row answers; the body inside is the stranger's, so it is quoted in `.brief`, verbatim, through no markdown pass at all. The board's address is taken as either its page on userinput.app or the `at://` URI (`feedbackSourceUri` in `@radial/sidecar`, one parser for the form and the CLI). |
-| `src/lib/issues.ts` | Issues on a project's tangled repository, offered as goals: the repository's own DID, the appview's issue list, the re-read from each filer's own repo that makes the index a hint, and which issues the space has already taken up. Another module reading outside the space's members, and nothing it returns enters the fold either. |
+| `src/lib/issues.ts` | Issues on a project's tangled repository, offered as goals: the repository's own DID, the appview's issue list, the re-read from each filer's own repo that makes the index a hint, and which issues the space has already taken up. `issueRows` joins a look to the fold at the point of drawing, exactly as `feedbackRows` does, so the network fan-out is a function of the remote and not of the sync tick. Another module reading outside the space's members, and nothing it returns enters the fold either. |
+| `src/routes/p/[project]/tangled/` | The section itself, and the Userinput page's twin down to its markup: one unit row per open issue — disc, tail, the forge's own state as a flat chip, title, drawer — with the issue's body quoted verbatim in `.brief` and never through a markdown pass. Importing writes nothing; it opens the goal composer seeded with the issue's words (`NewGoal.svelte`), which is where a member reads them, edits them, and signs the goal. |
| `src/lib/diagnostics.ts` | `index.ignored` and `index.edits`, grouped for display. |
| `src/lib/build.ts` | Which copy of the app this tab is running — the constants `scripts/build-stamp.mjs` reads at build time and `vite.config.ts` injects, plus the origin the bundle was built for. Pure, and every input is an argument, so the one runtime value (where the tab is actually served from) is passed in. See *Build info* below. |
| `src/lib/keys.ts`, `focus.ts` | The keyboard map, and focus restoration. |
@@ -146,11 +147,13 @@ what the toggle decides is whether the app asks.
### Issues on the forge, as candidate goals
-A project whose remote is on tangled has an **Issues on tangled** section at the foot of its page:
-the repository's open issues, offered as goals somebody could write. It is the same kind of surface
-as Community — a stranger's words this tab went and fetched — so it takes the same rail, the same
-muting and the same "not a member" badge, and its module (`issues.ts`) follows the same three rules
-`guests.ts` does.
+A project whose remote is on tangled gets a **Tangled** item in the rail, beside Userinput and under
+the same project: the repository's open issues, offered as goals somebody could write. It is the
+Userinput section's twin, and drawn as one — a page of unit rows, each with a drawer holding the
+issue's own words — because it is the same kind of surface answering the same question about a
+different stranger's service, and its module (`issues.ts`) follows the same three rules `guests.ts`
+does. A candidate that somebody has already taken up keeps its row and says so, exactly as a piece of
+feedback does; the ones a look never had to fetch are counted beside the section head.
**Nothing there is in the space, and no turn reads any of it.** Ingestion polls member repos for
`com.disnetdev.radial.*`, so an `sh.tangled.repo.issue` is in no `RecordStore`, no `materialize()`
@@ -161,15 +164,22 @@ edited if they wanted to. That is the whole prompt-injection answer, and it is w
one-press import: an issue tracker is open to anyone with an atproto account.
The goal carries `source` — `{kind: 'tangled-issue', uri, cid}`, the version that was read — and that
-is the only thing carried besides the text. It is what takes the issue out of the candidate list for
-everybody in the space rather than just for whoever imported it, and it is deliberately **not** in
+is the only thing carried besides the text. It is what says an issue has been taken up for everybody
+in the space rather than just for whoever imported it: a look does not even fetch one it can already
+account for, and a row it did fetch says who took it up and links the goal. It is deliberately **not** in
any turn bundle (`core/test/bundle.test.mjs` asserts that): an agent handed the at-uri could fetch
the live issue and read comments and edits nobody reviewed.
-The section does not look until it is asked to, and remembers the answer per browser profile. Tangled's
-appview (`api.tangled.org`, the same Bobbin the daemon reads pull state from) has no SLA and is
-somebody else's service, and a page that queried it on sight would also be telling tangled which
-repositories this browser reads. When it does look: the repository's own DID is resolved the way the
+The section does not look until the page is open, and opening it is the ask — the bargain the
+Userinput page already makes with somebody else's feedback board. Tangled's appview
+(`api.tangled.org`, the same Bobbin the daemon reads pull state from) has no SLA and is somebody
+else's service, and a project page that queried it on sight would also be telling tangled which
+repositories this browser reads; a rail item costs it nothing, and the page behind it is the first
+thing that asks it anything. Once open it looks once per **remote**, not once per sync tick: the
+space is republished every ten seconds whether or not a record moved, so everything the discovery
+effect reads is either a string that only changes when the answer would or is read under `untrack` —
+including this space's own goals, which make a look cheaper and must never make it happen. Refresh is
+the manual path. When it does look: the repository's own DID is resolved the way the
daemon's adapter resolves it — the owner's handle to a DID, then the `sh.tangled.repo` record in the
owner's repo, whose `repoDid` is a *different* DID from the owner's — the appview lists the issues,
and every candidate is then re-read from the repo of whoever filed it and must still say it belongs
diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css
index fd4a520..907c09c 100644
--- a/packages/ui/src/app.css
+++ b/packages/ui/src/app.css
@@ -739,13 +739,14 @@ a.btn { text-decoration: none; color: var(--ink); display: inline-block; }
.msg .pending .dot { width: 13px; height: 13px; flex: none; }
.msg .pending b { color: var(--ink); font-weight: 600; }
-/* ─── community, and candidate goals (words from outside the space) ───────── */
+/* ─── community (comments from outside the space) ────────────────────────── */
/* Apart from the thread on purpose. Everything above is a record this space's own fold admitted;
everything here is text a stranger wrote that the tab went and fetched. A guest row is inset
behind its own rail and reads a shade quieter, so the difference between "somebody here said
this" and "a stranger said this" is visible before any badge is read.
- Issues on the project's forge (`ProjectIssues.svelte`) are the same kind of thing arriving by a
- different route, so they get the same rail rather than a second visual language for it. */
+ The other two lists of outside words — a userinput board, and issues on the project's forge — are
+ pages of their own rather than a section beside a thread, so they are drawn as unit rows with the
+ stranger's prose quoted in `.brief` and take nothing from here. */
.community { margin-left: 2px; padding-left: 14px; border-left: 2px solid var(--line-soft); }
/* Profile resolution gives a guest the same disc-and-prose geometry as a thread message. Keeping
the body in column two makes it begin under the author's name, never under their avatar. */
@@ -755,14 +756,6 @@ a.btn { text-decoration: none; color: var(--ink); display: inline-block; }
/* Blessed: this one is in every turn bundle on the goal, which is worth the accent rail. */
.community .guest.blessed { border-left: 2px solid var(--accent); margin-left: -16px; padding-left: 14px; }
.community .guest .hint { font-size: 12.5px; color: var(--warn); }
-/* Candidate goals: issues on the project's forge, which nothing in this space has taken up yet. The
- rules are the guest row's, spelled out rather than shared with the selector above, because the two
- are alike by decision and not by construction — a candidate never gets the accent rail, since
- importing one produces a goal rather than putting the issue itself in front of a turn. */
-.candidates { margin-left: 2px; padding-left: 14px; border-left: 2px solid var(--line-soft); }
-.candidates .guest { color: var(--ink-2); }
-.candidates .guest .mb { grid-column: 2; }
-.candidates .guest .msg-acts { flex-wrap: wrap; align-items: baseline; }
/* ─── writing ────────────────────────────────────────────────────────────── */
/* Every field a human types a record into: the goal composer's two, the thread box, the request
diff --git a/packages/ui/src/lib/components/ProjectIssues.svelte b/packages/ui/src/lib/components/ProjectIssues.svelte
deleted file mode 100644
index 7827e3c..0000000
--- a/packages/ui/src/lib/components/ProjectIssues.svelte
+++ /dev/null
@@ -1,238 +0,0 @@
-
-
-{#if location}
-
- Open issues on
- {location.owner}/{location.repo}, offered as goals. Nothing here is in this space, and no
- turn reads any of it: importing one opens the goal composer with its words in it, and the goal
- you create is your own record.
-
-
- {#if error}
-
- ◉
-
- The issues could not be read.
- {error}
-
-
- {:else if found && found.failed > 0}
-
- ◉
-
- {found.failed} issue{found.failed === 1 ? '' : 's'} could not be read from the repo of whoever
- filed {found.failed === 1 ? 'it' : 'them'}, so {found.failed === 1 ? 'it is' : 'they are'} not
- shown.
-
-
- {/if}
-
- {#if !looked}
-
-
Issues are read from tangled's appview only when you ask.
-
-
-
-
- {:else if looking && !found}
-
Looking…
- {:else if rows.length === 0}
-
-
- {#if error}
- Nothing was read.
- {:else if imported > 0}
- Every open issue on tangled is already a goal here.
- {:else}
- No open issues on tangled.
- {/if}
-
+ Issues stay outside Radial, and away from agents, until a member reads one and imports it as a
+ goal.
+
+
+
+ {#if error}
+
Could not look: {error}
+ {/if}
+
+
+ {#if found && found.failed > 0}
+
+ {found.failed === 1 ? 'One issue was' : `${found.failed} issues were`} unavailable in the repo
+ of whoever filed {found.failed === 1 ? 'it' : 'them'}, or did not belong to this repository, so
+ {found.failed === 1 ? 'it is' : 'they are'} not listed.
+
+ {/if}
+ {#if found?.truncated && location}
+
+ More issues are open than this bounded view loaded — the rest are
+ on the forge.
+
+ {#if loading}Looking at the repository…{:else if error}Nothing could be read from this
+ repository.{:else if imported > 0}Every open issue on tangled is already a goal here.{:else}
+ No open issues on tangled.
+ {/if}
+
+ filed by {filer.handle ?? filer.name} · {stamp(row.createdAt)}
+ {#if !space.directory.get(row.did).active}
+ not a member of this space
+ {/if}
+
+ on the forge
+
+
+
+
+
{row.body}
+
+ {#if row.imported.length}
+
+ imported as
+ {#each row.imported as goal (goal.target.uri)}
+ {goal.target.value.title}
+ {/each}
+
+ {/if}
+
+
+ {#if writable}
+
+ {:else}
+ An active member can import this issue.
+ {/if}
+
+ {/if}
+
+
+
+
+ {/each}
+
+ {/if}
+{/if}
diff --git a/packages/ui/src/routes/p/[project]/tangled/tangled-page.svelte.test.ts b/packages/ui/src/routes/p/[project]/tangled/tangled-page.svelte.test.ts
new file mode 100644
index 0000000..11a7b1e
--- /dev/null
+++ b/packages/ui/src/routes/p/[project]/tangled/tangled-page.svelte.test.ts
@@ -0,0 +1,303 @@
+// @vitest-environment jsdom
+import { GOAL_SOURCE_TANGLED_ISSUE, type GoalView, type ProjectView } from '@radial/core'
+import { flushSync, mount, unmount } from 'svelte'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { compose } from '$lib/compose.svelte.js'
+import type { Space } from '$lib/space.js'
+import Page from './+page.svelte'
+
+// The Tangled section, mounted. It is the Userinput page's twin and its test is the same test, for
+// the same three claims:
+//
+// **An issue is drawn as a row of the same kind as everything else in the app** — the unit row's own
+// `.row` / `.tail` / `.sub` furniture with a drawer under it — rather than as a heading with a
+// paragraph loose beneath it. That is a visual claim, and the only honest way to make it is against
+// real mounted markup.
+//
+// **A stranger's prose reaches the screen as its own source.** An issue tracker is open to anyone
+// with an atproto account, so the body goes through no markdown pass at all and is interpolated by
+// Svelte: markup in an issue arrives as text and never as an element.
+//
+// **Importing writes nothing.** It opens the goal composer seeded with the issue's words, which is
+// where a human reads them, edits them, and signs the goal that is actually created.
+//
+// And one cost: the space around this page is republished every ten seconds whether or not a record
+// moved, while reading a repository is an appview call plus a PDS read per issue. So the space here
+// is reactive exactly as `session.svelte.ts` publishes it, and a tick that changes nothing must not
+// be a second look at somebody else's repository.
+
+const REPO = 'did:plc:repository'
+const OWNER = 'did:plc:owner'
+const FILER = 'did:plc:stranger'
+const MEMBER = 'did:plc:member'
+const SPACE = 'at://did:plc:member/com.disnetdev.radial.space/space'
+const PROJECT = 'at://did:plc:member/com.disnetdev.radial.project/p1'
+const ISSUE = `at://${FILER}/sh.tangled.repo.issue/3mrx`
+const HOSTILE = ' **not bold**'
+
+const appview = vi.hoisted(() => vi.fn())
+vi.stubGlobal('fetch', appview)
+
+vi.mock('$app/state', () => ({ page: { params: { project: 'widget' } } }))
+vi.mock('$lib/auth.svelte.js', () => ({
+ account: { status: 'signed-in', did: 'did:plc:member' },
+}))
+vi.mock('@radial/atproto', () => ({ resolveHandleDid: async () => OWNER }))
+vi.mock('$lib/space.js', async (original) => ({
+ ...(await original>()),
+ projectByName: () => project,
+}))
+vi.mock('$lib/session.svelte.js', () => ({
+ currentSpace: () => space,
+ spaceTransport: () => ({
+ resolvePds: async () => new URL('https://pds.test'),
+ listForeignRecords: async () => ({
+ records: [
+ {
+ uri: `at://${OWNER}/sh.tangled.repo/widget`,
+ cid: 'cid-repo',
+ value: { $type: 'sh.tangled.repo', name: 'widget', repoDid: REPO },
+ },
+ ],
+ }),
+ getForeignRecord: async (uri: string) => ({
+ uri,
+ cid: 'cid-issue',
+ value: {
+ $type: 'sh.tangled.repo.issue',
+ repo: REPO,
+ title: 'repo operations: archive repository',
+ body: HOSTILE,
+ createdAt: '2026-07-01T00:00:00Z',
+ },
+ }),
+ }),
+ identify: async (did: string) => ({
+ did,
+ active: false,
+ role: 'member',
+ kind: 'human',
+ name: 'stranger.test',
+ handle: 'stranger.test',
+ initials: 'ST',
+ color: 'purple',
+ artifactTypes: [],
+ }),
+}))
+
+const projectWith = (gitUrl = 'https://tangled.org/alice.example/widget'): ProjectView =>
+ ({
+ name: 'widget',
+ gitUrl,
+ archived: false,
+ target: { uri: PROJECT, did: MEMBER, value: { space: { uri: SPACE } } },
+ }) as unknown as ProjectView
+
+let project = projectWith()
+
+const goalFrom = (uri: string): GoalView =>
+ ({
+ target: {
+ uri: 'at://did:plc:member/com.disnetdev.radial.goal/g1',
+ did: MEMBER,
+ rkey: 'g1',
+ value: {
+ title: 'Archive a repository',
+ createdAt: '2026-07-02T00:00:00Z',
+ space: { uri: SPACE },
+ project: { uri: PROJECT },
+ source: { kind: GOAL_SOURCE_TANGLED_ISSUE, uri, cid: 'cid-issue' },
+ },
+ },
+ ended: false,
+ }) as unknown as GoalView
+
+/** A space as `publish()` builds one: a fresh object every tick, over whatever the fold now holds. */
+const spaceOver = (goals: GoalView[] = [], asOf = '2026-08-01T00:00:00Z'): Space =>
+ ({
+ uri: SPACE,
+ fixture: false,
+ asOf,
+ index: { goals, projects: [project] },
+ directory: {
+ get: (did: string) => ({
+ did,
+ active: did === MEMBER,
+ role: 'member',
+ kind: 'human',
+ name: did.slice(-6),
+ initials: 'GT',
+ color: 'purple',
+ artifactTypes: [],
+ }),
+ },
+ }) as unknown as Space
+
+let space = $state(spaceOver())
+
+let host: HTMLElement
+let component: Record | undefined
+
+beforeEach(() => {
+ appview.mockReset()
+ appview.mockResolvedValue(
+ new Response(JSON.stringify({ items: [{ uri: ISSUE, cid: 'cid-issue', state: 'open' }] }), {
+ status: 200,
+ }),
+ )
+ project = projectWith()
+ space = spaceOver()
+ document.title = 'clean'
+ host = document.createElement('div')
+ document.body.append(host)
+})
+
+afterEach(() => {
+ if (component) void unmount(component)
+ component = undefined
+ compose.draft = undefined
+ host.remove()
+})
+
+const render = (): void => {
+ component = mount(Page, { target: host, props: {} }) as Record
+ flushSync()
+}
+
+const rows = (): HTMLElement[] => [...host.querySelectorAll('article.unit')]
+const text = (): string => (host.textContent ?? '').replace(/\s+/g, ' ')
+const open = (): void => {
+ host.querySelector('button.row')?.click()
+ flushSync()
+}
+
+describe('the tangled section', () => {
+ it('draws each open issue as a row of the same kind as the rest of the app', async () => {
+ render()
+ await vi.waitFor(() => expect(rows()).toHaveLength(1))
+
+ const [first] = rows()
+ expect(first?.querySelector('button.row')?.getAttribute('aria-expanded')).toBe('false')
+ expect(first?.querySelector('.row .disc')).not.toBeNull()
+ expect(first?.querySelector('.row > .tail')).not.toBeNull()
+ expect(first?.querySelector('.row .sub')?.textContent?.trim()).toBe(
+ 'repo operations: archive repository',
+ )
+ // The forge's word for where this stands is a neutral chip, not a coloured badge: it is a state
+ // of record on somebody else's system (DESIGN.md — informative states are flat).
+ expect(first?.querySelector('.row .vchip')?.textContent?.trim()).toBe('open')
+ expect(first?.querySelector('.row .badge')).toBeNull()
+ // Closed, the body is not on the page at all — the row is a row.
+ expect(host.textContent).not.toContain('not bold')
+ })
+
+ it('opens a row and quotes the body rather than typesetting it', async () => {
+ render()
+ await vi.waitFor(() => expect(rows()).toHaveLength(1))
+ open()
+
+ const row = rows()[0]
+ expect(row?.classList.contains('open')).toBe(true)
+ expect(row?.querySelector('button.row')?.getAttribute('aria-expanded')).toBe('true')
+ // Verbatim, and never markup: no `` was created and no `**` was turned into emphasis.
+ expect(row?.querySelector('.drawer .brief')?.textContent).toBe(HOSTILE)
+ expect(host.querySelector('img')).toBeNull()
+ expect(host.querySelector('strong')).toBeNull()
+ expect(document.title).toBe('clean')
+ expect(text()).toContain('not a member of this space')
+
+ open()
+ expect(rows()[0]?.classList.contains('open')).toBe(false)
+ })
+
+ it('imports by opening the composer, seeded, and writing nothing', async () => {
+ render()
+ await vi.waitFor(() => expect(rows()).toHaveLength(1))
+ open()
+
+ const button = [...host.querySelectorAll('.drawer button.btn')].find((entry) =>
+ entry.textContent?.includes('Import as a goal'),
+ )
+ button?.click()
+ flushSync()
+
+ expect(compose.draft).toEqual({
+ kind: 'goal',
+ project: PROJECT,
+ seed: {
+ title: 'repo operations: archive repository',
+ body: HOSTILE,
+ did: FILER,
+ source: { kind: GOAL_SOURCE_TANGLED_ISSUE, uri: ISSUE, cid: 'cid-issue' },
+ },
+ })
+ })
+
+ it('says on the row what the space has already taken up, out of its own fold', async () => {
+ render()
+ await vi.waitFor(() => expect(rows()).toHaveLength(1))
+ expect(appview).toHaveBeenCalledTimes(1)
+
+ // The goal a member just wrote is folded by `write()`, so the row says so with no second trip to
+ // the forge — and the offer becomes "again", since importing twice is a decision, not a mistake.
+ space = spaceOver([goalFrom(ISSUE)])
+ flushSync()
+ expect(host.querySelector('.row .badge')?.textContent).toContain('imported')
+ expect(text()).toContain('1 imported')
+ open()
+ const links = [...host.querySelectorAll('.drawer .kv a')]
+ expect(links.map((link) => link.textContent?.trim())).toContain('Archive a repository')
+ expect(text()).toContain('Import again')
+ expect(appview).toHaveBeenCalledTimes(1)
+ })
+
+ // The repository is an appview call plus a read per issue; the space around it is republished on a
+ // ten second timer whether or not a record moved. Only the remote may drive a second look.
+ it('reads the repository once, not once per sync tick', async () => {
+ render()
+ await vi.waitFor(() => expect(rows()).toHaveLength(1))
+ expect(appview).toHaveBeenCalledTimes(1)
+
+ for (const asOf of ['2026-08-01T00:00:10Z', '2026-08-01T00:00:20Z']) {
+ space = spaceOver([], asOf)
+ flushSync()
+ await Promise.resolve()
+ flushSync()
+ }
+ expect(appview).toHaveBeenCalledTimes(1)
+ // The row survived the ticks: a drawer somebody had open is not pulled out from under them.
+ expect(rows()).toHaveLength(1)
+
+ // A different remote is a different question, and that one is asked.
+ project = projectWith('https://tangled.org/bob.example/widget')
+ space = spaceOver()
+ flushSync()
+ await vi.waitFor(() => {
+ flushSync()
+ expect(appview).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('says the issues could not be read rather than that there are none', async () => {
+ appview.mockResolvedValue(new Response('nope', { status: 502 }))
+ render()
+ await vi.waitFor(() => expect(text()).toContain('Could not look'))
+ expect(text()).toContain('502')
+ expect(text()).toContain('Nothing could be read from this repository')
+ })
+
+ it('sends a project whose remote is not on tangled to the settings that move it', async () => {
+ project = projectWith('https://github.com/acme/widget.git')
+ space = spaceOver()
+ render()
+ await Promise.resolve()
+ flushSync()
+
+ expect(text()).toContain("remote is not on tangled")
+ expect(host.querySelector('.empty a')?.getAttribute('href')).toBe(
+ `/p/widget/settings?space=${encodeURIComponent(SPACE)}`,
+ )
+ // Nothing was asked of tangled: there is nothing to ask about.
+ expect(appview).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/ui/test/thread-grid.test.mjs b/packages/ui/test/thread-grid.test.mjs
index 38ea527..5519de8 100644
--- a/packages/ui/test/thread-grid.test.mjs
+++ b/packages/ui/test/thread-grid.test.mjs
@@ -40,10 +40,15 @@ describe('the thread row grid', () => {
assert.match(css, /\.community \.guest \.mb \{[^}]*grid-column: 2/)
})
- it('does the same for a candidate goal, which is the same row from a different source', () => {
- const candidates = read('lib/components/ProjectIssues.svelte')
- assert.match(candidates, //)
- assert.match(candidates, /
\s*\{filer\.name\}<\/b>/)
- assert.match(css, /\.candidates \.guest \.mb \{[^}]*grid-column: 2/)
+ // The other two lists of outside words are not this grid at all: a userinput board and a
+ // repository's issues are pages of unit rows, so a stranger's prose is quoted in `.brief` inside a
+ // drawer rather than auto-placed into a disc's column. Nothing here has to hold them — but if one
+ // of them ever grows a `.guest` row again, the rules above are the ones it has to satisfy.
+ it('leaves no rules behind for a list that no longer draws message rows', () => {
+ assert.doesNotMatch(css, /\.candidates/)
+ for (const file of ['routes/p/[project]/tangled/+page.svelte', 'routes/p/[project]/userinput/+page.svelte']) {
+ assert.match(read(file), /