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
11 changes: 11 additions & 0 deletions ui/harness/workspace-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ export function workspaceExample(now: number, options: { music?: boolean } = {})
outcome: 'Compare the transfer curves across the two map resolutions.',
host: WORKSPACE_REMOTE,
},
{
id: 'research/workspace/validation-follow-up',
uid: '01KVBRDV8QS7GZU9GG23Z790Z0',
name: 'Validate the summary against simulations',
status: 'open',
age: 0.5,
outcome: 'Run the summary’s null tests on independent simulations before closing the loop.',
host: WORKSPACE_HOST,
},
{
id: 'research/workspace/method-note',
uid: '01KVBR6M1GJ0ZRM29956V023R3',
Expand Down Expand Up @@ -252,6 +261,7 @@ export function workspaceExample(now: number, options: { music?: boolean } = {})
modified_at: iso(-47 * minute),
closed_at: fiber.status === 'closed' ? iso(-fiber.age * day) : undefined,
...(fiber.id === 'research/workspace/weekly-summary' ? { depends_on: WORKSPACE_UID } : {}),
...(fiber.id === 'research/workspace/validation-follow-up' ? { depends_on: '01KVBR2G7CXDWMG85592QW78M9' } : {}),
...(fiber.collaboration ? { collaboration: fiber.collaboration } : {}),
shuttle: {
kind: fiber.id === 'pipeline/spin/remote-review' ? 'standing' : 'oneshot',
Expand Down Expand Up @@ -381,6 +391,7 @@ export function workspaceExample(now: number, options: { music?: boolean } = {})
].join('\n'),
'research/workspace/method-note': 'The response correction uses independent simulations and leaves the measured shear unchanged in the null tests.',
'research/workspace/weekly-summary': 'Weekly summary body.',
'research/workspace/validation-follow-up': 'Compare the summary’s null tests against independent simulations.',
'pipeline/spin/remote-review': 'The remote covariance review is running on the fixture host.',
'research/workspace/mask-validation': 'The mask validation passed.',
'pipeline/spin/transfer-check': 'Compare the transfer functions at both map resolutions.',
Expand Down
2 changes: 1 addition & 1 deletion ui/scripts/check-queue-drag.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ try {
await reviewHead.waitFor()
const queueChip = reviewHead.locator('.kbn-card-queued')
await queueChip.click()
const sourceRow = page.locator(`.kbn-card-queued-row`, { hasText: sourceName })
const sourceRow = reviewHead.locator('.kbn-card-queued-row', { hasText: sourceName })
await sourceRow.waitFor()
assert.equal(await queueChip.getAttribute('aria-expanded'), 'true')

Expand Down
2 changes: 1 addition & 1 deletion ui/src/board/KanbanReadModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ type AssembledSurfaces = {
* so work queued behind finished work stands in its own column rather than
* being tucked under something nobody is looking at.
*/
const FOLDABLE_HEAD_COLUMNS: ReadonlySet<KanbanColumn> = new Set<KanbanColumn>([
export const FOLDABLE_HEAD_COLUMNS: ReadonlySet<KanbanColumn> = new Set<KanbanColumn>([
'drafts',
'scheduled',
'pinned',
Expand Down
75 changes: 5 additions & 70 deletions ui/src/board/KanbanSurfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { appWorkerLink, terminalWorkerPill, workerVariant } from './appConversation.js'
import { workerPlate } from './workspace/workerPlate.js'
import { queuedControl } from './QueuedControl.js'
import { markVerdictHost } from './workspace/Verdicts.js'
import { humanizeIdleAge, renderMarkdown } from './utils.js'
import {
Expand Down Expand Up @@ -38,10 +39,8 @@ import {
queueIsLinear,
queueRowDetachPlan,
queueRowDropWrites,
queueMemberNote,
queueRowGesture,
queuedBehind,
queuedChipLabel,
reorderQueueWrites,
stackClaimsDrop,
stackDropVerdict,
Expand Down Expand Up @@ -2053,31 +2052,6 @@ export class KanbanSurfaceRenderer {

const resp = this.o.getLastResponse()
const members = queued.map((id) => findCardById(resp, id))
const names = members.map((m, i) => m?.name ?? queued[i])
const notes = members.map((m) => (m ? queueMemberNote(m) : null))

const chip = document.createElement('button')
chip.type = 'button'
// COMPACT is the same chip in a smaller room — a pinned launcher or a
// Resting row has no width for "+3 queued", so the words drop to `+3` and
// the count survives where it always has to: the tooltip and the aria
// label, both written below and both naming the number outright.
chip.className = opts.compact ? 'kbn-card-queued kbn-card-queued--compact' : 'kbn-card-queued'
chip.textContent = opts.compact ? `+${queued.length}` : queuedChipLabel(queued.length)
chip.setAttribute('aria-expanded', 'false')
chip.setAttribute(
'aria-label',
`${queued.length} card${queued.length === 1 ? '' : 's'} queued behind ${card.name} — show them`,
)
chip.title = `Waiting on this one, in order: ${names
.map((name, i) => (notes[i] ? `${name} (${notes[i]})` : name))
.join(' → ')}`

const list = document.createElement('ol')
list.className = opts.compact
? 'kbn-card-queued-list kbn-card-queued-list--floating'
: 'kbn-card-queued-list'
list.hidden = true
// Each row asks for itself. `chainAllScalar` is the only chain-wide fact in
// play, and it gates REORDER alone — taking a row out is a fact about that
// row's own fiber, so it stays offered even in a queue of one and even when
Expand All @@ -2093,6 +2067,7 @@ export class KanbanSurfaceRenderer {
const gestures = members.map(gestureFor)
const reorderable = gestures.some((g) => g.reorderable)
&& queueIsLinear(card.id, queued, members.filter((m): m is KanbanCard => !!m))
const { chip, list } = queuedControl(card, queued, members, member => this.o.openDetail(member), opts)
// THE LIST IS ITS OWN DRAG BOUNDARY.
//
// `dragstart` fires on the nearest DRAGGABLE ANCESTOR of the pressed
Expand Down Expand Up @@ -2133,60 +2108,20 @@ export class KanbanSurfaceRenderer {
list.classList.add('kbn-card-queued-list--reorderable')
chip.title = `${chip.title}. Drag a row to reorder the queue.`
}
names.forEach((name, i) => {
const li = document.createElement('li')
li.className = 'kbn-card-queued-row'
li.textContent = name
const note = notes[i]
if (note) {
// A closed member reads dimmer and says which closed state it is in —
// it is in the queue, but it is not what the queue is waiting on next.
// It also wears the state's OWN pigment: verdigris for awaiting review,
// the board's verdict colour, so a settled member is findable at a
// glance the moment the list is open.
li.classList.add('kbn-card-queued-row--settled')
li.classList.add(
note === 'awaiting review'
? 'kbn-card-queued-row--review'
: 'kbn-card-queued-row--discarded',
)
const suffix = document.createElement('span')
suffix.className = 'kbn-card-queued-note'
suffix.textContent = ` · ${note}`
li.append(suffix)
}
Array.from(list.children).forEach((child, i) => {
const li = child as HTMLLIElement
const gesture = gestures[i]
const canReorder = reorderable && gesture.reorderable
// The row SAYS what its drag can do — or, when it has none, why. A row
// that silently refuses to move reads as a broken board.
const hint = gesture.reorderable && !reorderable
? 'Drag out to move. This queue branches, so rows cannot be reordered.'
: gesture.hint
li.title = `Open “${name}”${note ? ` (${note})` : ''}. ${hint}`
// A ROW IS THE FIBER IT NAMES. Without this the click bubbles to the
// card the list hangs off and opens the HEAD — you click "Euclid
// timetracker", you get the card you were reading. The row is the only
// place some of these fibers appear on the board at all (the fold draws
// them here and nowhere else), so it has to be a way in.
const member = members[i]
if (member) {
li.dataset.cardUid = member.uid ?? member.id
li.dataset.cardOrigin = member.originId
}
li.addEventListener('click', (e) => {
e.stopPropagation()
if (member) this.o.openDetail(member)
})
li.title += ` ${hint}`
if (!gesture.draggable) li.classList.add('kbn-card-queued-row--fixed')
if (gesture.draggable) {
this.installQueueRowDrag(li, list, card.id, queued, i, canReorder)
}
list.append(li)
})
chip.addEventListener('click', (e) => {
e.stopPropagation()
list.hidden = !list.hidden
chip.setAttribute('aria-expanded', String(!list.hidden))
})
host.append(chip)
host.append(list)
Expand Down
61 changes: 61 additions & 0 deletions ui/src/board/QueuedControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { KanbanCard } from './KanbanTypes.js'
import { queueMemberNote, queuedChipLabel } from './KanbanRules.js'

/** Desk's queue peek, shared by every surface that draws a queue head. */
export function queuedControl(
card: KanbanCard,
ids: readonly string[],
members: readonly (KanbanCard | null | undefined)[],
open: (card: KanbanCard) => void,
opts: { compact?: boolean } = {},
): { chip: HTMLButtonElement; list: HTMLOListElement } {
const names = members.map((member, i) => member?.name ?? ids[i])
const notes = members.map(member => member ? queueMemberNote(member) : null)
const chip = document.createElement('button')
chip.type = 'button'
chip.className = opts.compact ? 'kbn-card-queued kbn-card-queued--compact' : 'kbn-card-queued'
chip.textContent = opts.compact ? `+${ids.length}` : queuedChipLabel(ids.length)
chip.setAttribute('aria-expanded', 'false')
chip.setAttribute('aria-label', `${ids.length} card${ids.length === 1 ? '' : 's'} queued behind ${card.name} — show them`)
chip.title = `Waiting on this one, in order: ${names.map((name, i) => notes[i] ? `${name} (${notes[i]})` : name).join(' → ')}`
const list = document.createElement('ol')
list.className = opts.compact ? 'kbn-card-queued-list kbn-card-queued-list--floating' : 'kbn-card-queued-list'
list.hidden = true
names.forEach((name, i) => {
const row = document.createElement('li')
row.className = 'kbn-card-queued-row'
row.textContent = name
const note = notes[i]
if (note) {
row.classList.add('kbn-card-queued-row--settled', note === 'awaiting review' ? 'kbn-card-queued-row--review' : 'kbn-card-queued-row--discarded')
const suffix = document.createElement('span')
suffix.className = 'kbn-card-queued-note'
suffix.textContent = ` · ${note}`
row.append(suffix)
}
row.title = `Open “${name}”${note ? ` (${note})` : ''}.`
const member = members[i]
if (member) {
row.dataset.cardUid = member.uid ?? member.id
row.dataset.cardOrigin = member.originId
}
row.tabIndex = 0
row.setAttribute('role', 'button')
row.addEventListener('click', event => {
event.stopPropagation()
if (member) open(member)
})
row.addEventListener('keydown', event => {
if (!['Enter', ' '].includes(event.key)) return
event.preventDefault(); event.stopPropagation()
if (!event.repeat && member) open(member)
})
list.append(row)
})
chip.addEventListener('click', event => {
event.stopPropagation()
list.hidden = !list.hidden
chip.setAttribute('aria-expanded', String(!list.hidden))
})
return { chip, list }
}
4 changes: 2 additions & 2 deletions ui/src/board/deskKeyboard.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ describe('Desk keyboard selection', () => {
})
it('opens the reader on one grouped sidebar, and keeps the flight column for the cards that fly', () => {
press('j'); press('h'); press('Enter')
expect([...document.querySelectorAll<HTMLElement>('.ws-sidebar .ws-channel-row')].map(el => el.dataset.channelUid)).toEqual(['draft-uid', 'd2', 'child-uid', 'working', 'head-uid', 'review'])
expect([...document.querySelectorAll<HTMLElement>('.ws-sidebar .ws-channel-row')].map(el => el.dataset.channelUid)).toEqual(['draft-uid', 'd2', 'working', 'head-uid', 'review'])
expect([...document.querySelectorAll('.ws-sidebar .kbn-flight-caption')].map(el => el.textContent)).toEqual(['Drafts', 'Aloft', 'Holding', 'Awaiting review'])
document.querySelector<HTMLButtonElement>('.ws-return')!.click()
document.querySelector<HTMLElement>('[data-fiber-id="head"] .kbn-card-queued')!.click()
Expand All @@ -147,7 +147,7 @@ describe('Desk keyboard selection', () => {
press('j'); press('h')
document.querySelector<HTMLElement>('[data-fiber-id="head"] .kbn-card-queued')!.click()
press('Enter')
expect([...document.querySelectorAll<HTMLElement>('.ws-sidebar .ws-channel-row')].map(el => el.dataset.channelUid)).toEqual(['draft-uid', 'd2', 'child-uid', 'working', 'head-uid', 'review'])
expect([...document.querySelectorAll<HTMLElement>('.ws-sidebar .ws-channel-row')].map(el => el.dataset.channelUid)).toEqual(['draft-uid', 'd2', 'working', 'head-uid', 'review'])
})
it('survives refresh reorder and a path rename by uid+origin, not list position', () => {
press('j'); press('h'); press('h')
Expand Down
9 changes: 6 additions & 3 deletions ui/src/board/workspace/ConstitutionPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface ConstitutionPickerOptions {
current?(card: KanbanCard): boolean
revealCurrent?: boolean
renderCard?(card: KanbanCard): HTMLElement
/** Includes external data used by a retained card's face, such as its queue. */
revision?(card: KanbanCard): string
group?(card: KanbanCard): string | undefined
/** The retained root, after placement and revision patching, on every refresh. */
onRow?(el: HTMLElement, card: KanbanCard): void
Expand Down Expand Up @@ -113,6 +115,7 @@ export class ConstitutionPicker {
}
previousGroup = group
const key = identity(card)
const revision = this.opts.revision?.(card) ?? JSON.stringify(card)
let row = this.rows.get(key)
if (!row) {
const el = this.opts.renderCard?.(card) ?? document.createElement('button')
Expand All @@ -122,7 +125,7 @@ export class ConstitutionPicker {
const name = el.querySelector<HTMLElement>('.ws-channel-name') ?? document.createElement('span'); name.classList.add('ws-channel-name')
const owner = el.querySelector<HTMLElement>('.ws-channel-owner') ?? document.createElement('small')
if (!this.opts.renderCard) el.append(name, owner)
row = { el, name, owner, card, revision: JSON.stringify(card) }
row = { el, name, owner, card, revision }
const record = row
const open = (): void => { this.close(); this.opts.onOpen(record.card) }
el.addEventListener('click', event => {
Expand All @@ -136,13 +139,13 @@ export class ConstitutionPicker {
})
this.rows.set(key, row)
}
if (this.opts.renderCard && row.revision !== JSON.stringify(card)) {
if (this.opts.renderCard && row.revision !== revision) {
const face = this.opts.renderCard(card)
row.el.replaceChildren(...face.childNodes)
row.name = row.el.querySelector<HTMLElement>('.ws-channel-name')!
row.owner = row.el.querySelector<HTMLElement>('.ws-channel-owner')!
}
row.revision = JSON.stringify(card)
row.revision = revision
row.card = card
row.el.dataset.channelUid = card.uid ?? card.id
row.el.dataset.channelOwner = card.originId
Expand Down
Loading
Loading