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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions ui/harness/harness-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
import { KanbanModal } from '../src/board/KanbanModal.js'
import { scopeTheme } from '../src/board/workspace/themeScope.js'
import { workshopExample } from './workshop-example.js'
import { installWorkspaceNativeURLs, ROLE_NOTES, WORKSPACE_HOST, workspaceExample } from './workspace-fixtures.js'
import { installWorkspaceNativeURLs, NOTE_FIBERS, WORKSPACE_HOST, workspaceExample } from './workspace-fixtures.js'
import { openCapture, openStash, openSettings } from '../src/forms/mountForms.js'
import { showToast } from '../src/board/utils.js'
import type {
Expand Down Expand Up @@ -1274,14 +1274,14 @@ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
},
}] })
}
const role = ROLE_NOTES.find(note => note.id === id)
if (role) {
const note = NOTE_FIBERS.find(row => row.id === id)
if (note) {
return json({ fibers: [{
origin: WORKSPACE_HOST,
felt_store: '/fixture-store/workspace',
path: `.felt/${id}/${id.split('/').at(-1)}.md`,
dir: `/fixture-store/workspace/.felt/${id}`,
fiber: { id, uid: role.uid, name: role.name, status: 'open', outcome: role.outcome, body: role.body, tags: ['role'] },
fiber: { id, uid: note.uid, name: note.name, status: 'open', outcome: note.outcome, body: note.body, tags: id.startsWith('roles/') ? ['role'] : ['workspace'] },
}] })
}
return json({ fibers: [] }, 404)
Expand All @@ -1306,7 +1306,7 @@ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
// The parent picker's index: the feed's rows plus a sibling of the null-test
// run, so its picker offers a parent before anything is typed.
if (url.endsWith('/api/v1/fibers?fields=index')) {
if (workspaceFixture) return json({ fibers: [...workspaceFixture.feed.fibers, { fiber: { id: 'research/workspace/method-note', name: 'Method note' } }, ...ROLE_NOTES.map(({ id, name }) => ({ fiber: { id, name } }))] })
if (workspaceFixture) return json({ fibers: [...workspaceFixture.feed.fibers, { fiber: { id: 'research/workspace/method-note', name: 'Method note' } }, ...NOTE_FIBERS.map(({ id, name }) => ({ fiber: { id, name } }))] })
if (docsExample) return json({ fibers: docsExample.feed.fibers })
return json({ fibers: [...MOCK_FEED.fibers, { fiber: { id: 'work/spt3g_papers/bmodes-2d/null-suite', name: 'Null-test suite' } }] })
}
Expand Down
34 changes: 30 additions & 4 deletions ui/harness/workspace-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,18 @@ export function installWorkspaceNativeURLs(example: WorkspaceExample): Workspace
return { blobURLs, rewrites }
}

/** The role store's fibers the fixture rosters name: a role and its holder, each a plain note. */
export const ROLE_NOTES = [
/**
* Plain notes the fixture links to, none Shuttle-managed: two roles from the
* role store (one held by two holders and named by three rosters, one held
* by no one and named by none), the holder pages, and a note outside it.
*/
export const NOTE_FIBERS = [
{
id: 'roles/surveyor',
uid: '01KVBR8P3JM2BTP4BB78T245T5',
name: 'Surveyor',
outcome: 'Walks a project end to end and maps what is there before anyone builds on it.',
body: 'The surveyor reads before it writes: the code, the data products and the open questions, in that order.\n\nHeld by [[roles/surveyor/opus]].',
body: 'The surveyor reads before it writes: the code, the data products and the open questions, in that order.\n\nIts working terms are in [[research/workspace/glossary]]; a role with no work yet is [[roles/scribe]].',
},
{
id: 'roles/surveyor/opus',
Expand All @@ -117,6 +121,27 @@ export const ROLE_NOTES = [
outcome: 'Opus holds the surveyor role across the workspace projects.',
body: 'Notes this holder keeps between surveys.',
},
{
id: 'roles/surveyor/sonnet',
uid: '01KVBRAR5MP4DWR6DD90W467W7',
name: 'Surveyor: sonnet',
outcome: 'Sonnet holds the surveyor role for quick passes.',
body: 'Notes this holder keeps between passes.',
},
{
id: 'roles/scribe',
uid: '01KVBRBS6NQ5EXS7EE01X578X8',
name: 'Scribe',
outcome: 'Keeps the record of what was decided and why.',
body: 'No roster names the scribe yet, and no one holds it.',
},
{
id: 'research/workspace/glossary',
uid: '01KVBRCT7PR6FYT8FF12Y689Y9',
name: 'Glossary',
outcome: 'The terms the workspace projects share.',
body: '**Transfer function**: the ratio of recovered to injected power, per scale.\n\n**Null test**: a difference map that should hold no signal.',
},
]

export const MUSIC_UID = '01KVBR7N2HK1ASN3AA67W134S4'
Expand Down Expand Up @@ -152,6 +177,7 @@ export function workspaceExample(now: number, options: { music?: boolean } = {})
age: 1,
outcome: 'Collect the latest validation results and note what remains uncertain.',
host: WORKSPACE_HOST,
collaboration: { surveyor: ['sonnet'] },
},
{
id: 'pipeline/spin/remote-review',
Expand Down Expand Up @@ -384,7 +410,7 @@ export function workspaceExample(now: number, options: { music?: boolean } = {})
return { id: String(fiber.id), name: String(fiber.name) }
}),
{ id: 'research/workspace/method-note', name: 'Method note' },
...ROLE_NOTES.map(({ id, name }) => ({ id, name })),
...NOTE_FIBERS.map(({ id, name }) => ({ id, name })),
]
const fileMap = new Map(files.map(item => [key(item.owner, item.path), item]))
const fileResponse = (url: string, method: string, requestHeaders?: HeadersInit): Response => {
Expand Down
8 changes: 7 additions & 1 deletion ui/src/board/workspace/FiberProse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import '../prose.css'
import './fiber-prose.css'
import type { Channel } from './documents.js'
import { fiberPageKicker } from './fiberPageState.js'
import { buildRoleLedger, roleSlug } from './RolePage.js'

/** The outcome as the reading surface's lede, including math and references. */
export function ledeHtml(outcome: string): string {
Expand Down Expand Up @@ -99,6 +100,9 @@ export function buildFiberProse(
acts?: HTMLElement
onFiber: (id: string) => void
onFile: (path: string, title?: string) => void
/** On a role page, the constitutions whose roster names the role, in Desk order. */
holds?: KanbanCard[]
onCard?: (card: KanbanCard) => void
},
): HTMLElement {
const scroller = document.createElement('div')
Expand Down Expand Up @@ -143,7 +147,9 @@ export function buildFiberProse(
}
installBodyFileLinks(body, opts.onFile)
void installWikilinks(body, { shuttleBase: opts.shuttleBase, onOpen: opts.onFiber })
article.append(header, title, outcome, ...(opts.controls ? [opts.controls] : []), body)
const slug = roleSlug(card)
const ledger = slug ? buildRoleLedger(slug, opts.holds ?? [], { shuttleBase: opts.shuttleBase, onFiber: opts.onFiber, onCard: opts.onCard ?? (() => {}) }) : null
article.append(header, title, outcome, ...(ledger ? [ledger] : []), ...(opts.controls ? [opts.controls] : []), body)
scroller.append(article)
return scroller
}
13 changes: 13 additions & 0 deletions ui/src/board/workspace/Reader.sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,19 @@ describe('Reader channel sidebar', () => {
expect(onChannel).toHaveBeenCalledWith(alpha)
})

it('marks only a fiber on the Desk lifecycle with its glyph', () => {
storage.set('shuttle:workspace:sidebar', 'true')
const managed = { ...gamma, shuttleKind: 'oneshot' as const }
channels.splice(2, 1, managed)
listedCards = channels
try {
const reader = makeReader(alpha)
const glyph = (uid: string) => reader.el.querySelector(`.ws-sidebar [data-channel-uid="${uid}"] .kbn-card-glyph`)
expect(glyph('alpha')).toBeNull()
expect(glyph('gamma')?.textContent).toBe('◐')
} finally { channels.splice(2, 1, gamma) }
})

it('refreshes sidebar rows without clearing the find text', () => {
viewport.wide = true
const reader = makeReader()
Expand Down
4 changes: 3 additions & 1 deletion ui/src/board/workspace/Reader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import './tokens.css'
import './reader.css'
import type { KanbanCard } from '../KanbanTypes.js'
import { verdictReachable } from './fiberPageState.js'
import { onDesk, verdictReachable } from './fiberPageState.js'
import { keyIntent, shouldForwardDocumentKey, type KeyIntent } from '../keymap.js'
import { blockingDialogOpen } from '../views/ViewRegistry.js'
import { MOBILE_MEDIA } from '../mobile.js'
Expand Down Expand Up @@ -827,6 +827,8 @@ export class Reader {
captureSidebar(entries: SidebarEntry[]): void { this.sidebarFlight.capture(entries) }
private sidebarCard(card: KanbanCard): HTMLElement {
const face = buildCardPaper(card)
// A note or role has no lifecycle, so it carries no lifecycle glyph.
if (!onDesk(card)) face.querySelector('.kbn-card-glyph')?.remove()
face.classList.add('ws-constitution-card')
face.dataset.part = 'sidebar-card'
face.dataset.wsThemeBoundary = ''
Expand Down
88 changes: 88 additions & 0 deletions ui/src/board/workspace/RolePage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { card } from '../testFixtures.js'
import type { KanbanCard } from '../KanbanTypes.js'
import type { Channel } from './documents.js'
import { buildFiberProse } from './FiberProse.js'
import { roleHolds, roleSlug } from './RolePage.js'

const role = card({ id: 'roles/surveyor', uid: 'surveyor', name: 'Surveyor', outcome: 'Maps a project.' })
const channel: Channel = { uid: 'surveyor', owner: 'local', name: 'Surveyor', body: 'Reads first.', labels: ['Note'], documents: [] }
const work = (id: string, over: Partial<KanbanCard>): KanbanCard =>
card({ id, uid: id, name: id, shuttleKind: 'oneshot', roles: ['surveyor'], ...over })
const draft = work('draft', { status: 'open', outcome: 'A *draft* to write.' })
const flying = work('flying', { status: 'active', workerState: 'running', tmuxSession: 'flying-shuttle' })
const review = work('review', { status: 'closed' })
const tempered = work('tempered', { status: 'closed', tempered: true })

let base = 0
function daemon(ids: string[]): string {
const shuttleBase = `http://role-page-${++base}.invalid`
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ fibers: ids.map(id => ({ fiber: { id, name: id } })) }))))
return shuttleBase
}
const settle = () => new Promise(resolve => setTimeout(resolve, 0))
const names = (root: ParentNode, selector: string) => [...root.querySelectorAll(selector)].map(el => el.textContent)

afterEach(() => { vi.unstubAllGlobals() })

describe('role page', () => {
it('is exactly a fiber at roles/<slug>, never a holder beneath it or a role elsewhere', () => {
expect(roleSlug(role)).toBe('surveyor')
expect(roleSlug(card({ id: 'roles/surveyor/opus' }))).toBeNull()
expect(roleSlug(card({ id: 'projects/roles/surveyor' }))).toBeNull()
expect(roleSlug(card({ id: 'roles' }))).toBeNull()
})

it('holds the constitutions whose roster names the role, in the Desk order', () => {
const other = work('other', { roles: ['scribe'] })
expect(roleHolds([tempered, draft, other, flying, review], 'surveyor').map(c => c.id)).toEqual(['review', 'flying', 'draft', 'tempered'])
})

it('lists holds and holders under the lede, before the body, and opens each', async () => {
const shuttleBase = daemon(['roles/surveyor', 'roles/surveyor/opus', 'roles/surveyor/fable', 'roles/surveyor/opus/notes', 'roles/scribe/opus'])
const onCard = vi.fn(), onFiber = vi.fn()
const pane = buildFiberProse(role, channel, { shuttleBase, onFiber, onFile: vi.fn(), holds: [review, flying, draft], onCard })
await settle(); await settle()
const ledger = pane.querySelector<HTMLElement>('[data-part="role-ledger"]')!
expect(ledger.previousElementSibling?.querySelector('.kbn-detail-lede')).not.toBeNull()
expect(ledger.nextElementSibling?.classList.contains('ws-prose-body')).toBe(true)
expect(names(ledger, '.ws-role-caption')).toEqual(['Held by', 'Holds'])
// Only the direct children are holders.
expect(names(ledger, '.ws-role-holder')).toEqual(['fable', 'opus'])
ledger.querySelector<HTMLAnchorElement>('.ws-role-holder a.kbn-wikilink-live')!.click()
expect(onFiber).toHaveBeenCalledWith('roles/surveyor/fable')
const rows = [...ledger.querySelectorAll<HTMLButtonElement>('.ws-role-hold')]
expect(rows.map(row => row.dataset.column)).toEqual(['awaitingReview', 'inFlight', 'drafts'])
expect(names(ledger, '.ws-role-hold-column')).toEqual(['Review', 'In flight', 'Draft'])
expect(rows[2].querySelector('.ws-role-hold-outcome')?.textContent).toBe('A draft to write.')
rows[1].click()
expect(onCard).toHaveBeenCalledWith(flying)
})

it('shows live work whole and folds the rest past five rows', async () => {
const settled = Array.from({ length: 6 }, (_, i) => work(`done-${i}`, { status: 'closed', tempered: true }))
const holds = [review, flying, ...settled]
const pane = buildFiberProse(role, channel, { shuttleBase: daemon([]), onFiber: vi.fn(), onFile: vi.fn(), holds, onCard: vi.fn() })
expect(pane.querySelectorAll('.ws-role-hold')).toHaveLength(5)
const more = pane.querySelector<HTMLButtonElement>('.ws-role-more')!
expect(more.textContent).toBe('3 more')
more.click()
expect(pane.querySelectorAll('.ws-role-hold')).toHaveLength(8)
expect(pane.querySelector('.ws-role-more')).toBeNull()
})

it('draws no heading for a role that holds nothing and no one holds', async () => {
const pane = buildFiberProse(role, channel, { shuttleBase: daemon(['roles/surveyor']), onFiber: vi.fn(), onFile: vi.fn(), holds: [] })
await settle(); await settle()
expect(pane.querySelector('.ws-role-caption')).toBeNull()
expect(pane.querySelector<HTMLElement>('[data-part="role-holders"]')?.hidden).toBe(true)
})

it('gives a holder page and any other fiber no ledger', () => {
for (const id of ['roles/surveyor/opus', 'notes/task']) {
const pane = buildFiberProse(card({ id }), channel, { shuttleBase: daemon([]), onFiber: vi.fn(), onFile: vi.fn(), holds: [draft] })
expect(pane.querySelector('[data-part="role-ledger"]')).toBeNull()
}
})
})
Loading
Loading