@hachej/boring-ui-kit 0.4.29 → 0.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +6 -0
  2. package/dist/chat-artifacts.d.ts +36 -19
  3. package/dist/chat-artifacts.js +48 -30
  4. package/dist/chat-react.d.ts +15 -17
  5. package/dist/chat-react.js +7 -6
  6. package/dist/task-artifacts.d.ts +22 -0
  7. package/dist/task-artifacts.js +34 -0
  8. package/dist/task-board.d.ts +83 -0
  9. package/dist/task-board.js +42 -0
  10. package/package.json +6 -2
  11. package/r/agent-target.json +4 -4
  12. package/r/app-shell.json +13 -12
  13. package/r/board-viewer.json +6 -6
  14. package/r/button.json +1 -1
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +12 -12
  17. package/r/feedback-preview.json +1 -1
  18. package/r/feedback.json +7 -7
  19. package/r/file-tree.json +4 -4
  20. package/r/highlights.json +3 -3
  21. package/r/html-viewer.json +3 -3
  22. package/r/looks-viewer.json +2 -2
  23. package/r/maquette-viewer.json +1 -1
  24. package/r/markdown-editor.json +3 -3
  25. package/r/mockup-viewer.json +2 -2
  26. package/r/pi-ambient.json +9 -9
  27. package/r/pi-app-kinds.json +10 -10
  28. package/r/pi-app.json +12 -12
  29. package/r/pi-artifacts.json +4 -4
  30. package/r/pi-chat-approval.json +2 -2
  31. package/r/pi-chat-attachments.json +1 -1
  32. package/r/pi-chat-markdown.json +2 -2
  33. package/r/pi-chat-mentions.json +2 -2
  34. package/r/pi-chat-pickers.json +1 -1
  35. package/r/pi-chat-plugins.json +11 -11
  36. package/r/pi-chat-question.json +1 -1
  37. package/r/pi-chat-slash.json +2 -2
  38. package/r/pi-chat-subagent.json +1 -1
  39. package/r/pi-chat-voice.json +3 -3
  40. package/r/pi-chat.json +10 -10
  41. package/r/pi-workspace.json +3 -3
  42. package/r/placed-agent.json +4 -4
  43. package/r/pm-panel.json +92 -0
  44. package/r/provider-setup.json +1 -1
  45. package/r/registry.json +386 -166
  46. package/r/requests-board.json +1 -1
  47. package/r/shared-chat.json +6 -6
  48. package/r/task-board.json +35 -0
  49. package/r/task-list-viewer.json +2 -2
  50. package/r/task-mentions.json +25 -0
  51. package/r/theme.json +88 -3
  52. package/r/version-history.json +2 -2
  53. package/r/viewer-code.json +2 -2
  54. package/r/viewer-frame.json +4 -4
  55. package/r/viewer-html.json +4 -4
  56. package/r/viewer-image.json +2 -2
  57. package/r/viewer-markdown.json +5 -5
  58. package/r/viewer-pdf.json +3 -3
  59. package/r/viewers.json +6 -6
  60. package/r/windows.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,12 @@ For read-only content without a resource client or controller, select the lightw
16
16
 
17
17
  See [scaffold guide](../../docs/contracts/SCAFFOLD.md) and [audit](../../docs/contracts/ABSTRACTION-REVIEW.md).
18
18
 
19
+ ## Business task board
20
+
21
+ `@hachej/boring-ui-kit/task-board` is a dependency-free host-record model: `TaskRecord`, `TaskState`, `TaskFilter`, `selectTasks`, `taskReference`, `taskFromUrl`, `taskUrl` and `TASK_STATES`. `selectTasks(tasks, { filter, currentUser, limit })` filters then sorts newest-first without mutating input. `mine` requires the host adapter's `requesterId`; it is never an authorization boundary. These business tasks are not native Pi execution tasks. The model also accepts host display metadata (`age`, `requester`, `refiner`, `captureCount`, `folder`), question `askedBy`/`askedAt`, and file `kind: 'markdown' | 'mockup' | 'capture'`. These fields never determine ownership. `deriveTaskStatus` prioritizes the native viewer flag, explicit `waiting_on.type: 'builder'`, then high risk; unknown status yields `none`. `taskStatusLabel(status, locale?, viewerAction?)` supplies EN/FR question versus confirmation wording without inferring workflow authority.
22
+
23
+ Copyable `TaskBoard`, `TaskDetail`, `TaskRequestCard` (also `TaskCard`) and `TaskStatusChip` presentation lives in the [`task-board` registry item](../../registry/README.md#task-board-task-board), using the app's Base UI controls/tokens. The [feature map](../../features/task-board.md) and [fictional example](../../examples/task-board/README.md) drive responsive columns/detail, URL navigation and compact own-request rows.
24
+
19
25
  ## Exact proposals and commands
20
26
 
21
27
  `actions.propose(selection, edits, summary)` retains an immutable proposal with its exact resource/scope/view/revision, viewer instance/epoch, buffer version, before/after text and ordered edits. It performs no publication and preserves existing human text. Every nonempty `find` must occur once; a missing or ambiguous edit rejects the entire transform.
@@ -1,5 +1,8 @@
1
1
  import type { ConversationView } from '@earendil-works/pi-durable';
2
2
  import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
3
+ import type { TaskArtifactDescriptor, TaskSubject } from './task-artifacts.js';
4
+ export { createTaskArtifact, parseTaskArtifact, parseTaskSubject, taskSubjectKey } from './task-artifacts.js';
5
+ export type { TaskArtifactDescriptor, TaskSubject } from './task-artifacts.js';
3
6
  /**
4
7
  * An artifact a tool call made (or a running `present` call is making), as the chat places it. `descriptor` is the host's own
5
8
  * value: the chat hands it back to the host's `open` and `isOpen` and never reads it.
@@ -48,14 +51,34 @@ export interface ArtifactDescriptor {
48
51
  readonly ordinal?: number;
49
52
  }
50
53
  /** What identifies an artifact across its versions: its id, or for a presented file its path. */
51
- export declare const artifactKey: (artifact: Pick<ArtifactDescriptor, "id" | "target">) => string;
54
+ export type PresentedArtifact = ArtifactDescriptor | TaskArtifactDescriptor;
55
+ export declare const artifactKey: (artifact: Pick<ArtifactDescriptor, "id" | "target"> | TaskArtifactDescriptor) => string;
56
+ /** File revisions are retained history; a task reference has no invented revision. */
57
+ export declare const artifactVersionKey: (artifact: PresentedArtifact) => string;
58
+ export declare function parsePresentedArtifact(value: unknown): PresentedArtifact | undefined;
52
59
  export declare const PRESENT_TOOL = "present";
53
60
  /** A `present` call with no result yet, while its conversation runs: its answer ID (`[assistantEntryId, callId]`) and the path it names. */
54
- export interface PendingPresent {
61
+ export type PendingPresent = {
55
62
  readonly id: string;
56
63
  readonly callId: string;
64
+ } & ({
57
65
  readonly path: string;
58
- }
66
+ } | {
67
+ readonly subject: TaskSubject;
68
+ });
69
+ export type PresentationShown = ({
70
+ readonly path: string;
71
+ readonly revision: string;
72
+ } | {
73
+ readonly subject: TaskSubject;
74
+ });
75
+ export type PresentationFailed = ({
76
+ readonly path: string;
77
+ } | {
78
+ readonly subject: TaskSubject;
79
+ }) & {
80
+ readonly reason: string;
81
+ };
59
82
  /** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */
60
83
  export declare const presentPath: (path: string) => string;
61
84
  /**
@@ -77,7 +100,7 @@ export declare function parseArtifact(value: unknown): ArtifactDescriptor | unde
77
100
  * the host's `detect`, only a result of `present` counts: any other tool whose text happens
78
101
  * to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.
79
102
  */
80
- export declare function detectArtifact(call: ToolCall, result: ToolResultMessage, detect?: ArtifactDetect | undefined): ArtifactDescriptor | undefined;
103
+ export declare function detectArtifact(call: ToolCall, result: ToolResultMessage, detect?: ArtifactDetect | undefined): PresentedArtifact | undefined;
81
104
  /** The title a still-running `present` call announces in its streamed arguments: the file name. */
82
105
  export declare function pendingTitle(call: ToolCall): string | undefined;
83
106
  /** What a still-running `present` call will show: the file path. */
@@ -86,7 +109,7 @@ export declare function pendingId(call: ToolCall): string | undefined;
86
109
  * Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known
87
110
  * from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.
88
111
  */
89
- export declare function collectArtifacts(view: ConversationView | undefined, detect?: ArtifactDetect | undefined): ArtifactDescriptor[];
112
+ export declare function collectArtifacts(view: ConversationView | undefined, detect?: ArtifactDetect | undefined): PresentedArtifact[];
90
113
  /** What the host's chat answer returns: the kind is all the presents bookkeeping reads. */
91
114
  export interface PresentAnswer {
92
115
  readonly kind: string;
@@ -96,6 +119,8 @@ export interface PresentAcksHost {
96
119
  readonly answer: (id: string, answer: string) => Promise<PresentAnswer> | undefined;
97
120
  /** Open the file in the panel; false when the panel refuses (unsaved changes the person keeps). */
98
121
  readonly open: (path: string) => boolean;
122
+ /** Open a host task reference; absent: it stays pending until a manual mount or deadline. */
123
+ readonly openTask?: ((subject: TaskSubject) => boolean) | undefined;
99
124
  /** This page's identity in the acknowledgement. */
100
125
  readonly instanceId: string;
101
126
  /** Test seams: the wait between retries and the clock of the retry bound. Default: real timers. */
@@ -114,6 +139,7 @@ export interface PresentContext {
114
139
  readonly autoOpen: boolean;
115
140
  /** Whether the panel already shows this file. */
116
141
  readonly showing: (path: string) => boolean;
142
+ readonly showingTask?: ((subject: TaskSubject) => boolean) | undefined;
117
143
  }
118
144
  /**
119
145
  * The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the
@@ -122,20 +148,11 @@ export interface PresentContext {
122
148
  */
123
149
  export declare function createPresentAcks(host: PresentAcksHost): {
124
150
  /** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */
125
- shown(view: {
126
- readonly path: string;
127
- readonly revision: string;
128
- }): void;
151
+ shown(view: PresentationShown): void;
129
152
  /** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */
130
- failed(view: {
131
- readonly path: string;
132
- readonly reason: string;
133
- }): void;
153
+ failed(view: PresentationFailed): void;
134
154
  /** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */
135
- hidden(view: {
136
- readonly path: string;
137
- readonly revision: string;
138
- }): void;
155
+ hidden(view: PresentationShown): void;
139
156
  /** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */
140
157
  consider(calls: readonly PendingPresent[], context: PresentContext): void;
141
158
  /** Calls still waiting for a mount (test and debugging view). */
@@ -149,9 +166,9 @@ export type PresentAcks = ReturnType<typeof createPresentAcks>;
149
166
  */
150
167
  export declare function recogniseArtifact(call: ToolCall, result: ToolResultMessage | undefined, running: boolean, detect: ArtifactDetect | undefined): ChatArtifact | undefined;
151
168
  /** Every version, newest conversation result first. Ordinals can tie across retries and never determine recency. */
152
- export declare function artifactVersions(view: ConversationView | undefined, detect?: ArtifactDetect): ArtifactDescriptor[];
169
+ export declare function artifactVersions(view: ConversationView | undefined, detect?: ArtifactDetect): PresentedArtifact[];
153
170
  /** Whether this descriptor is the newest known revision of its artifact, never an ordinal comparison. */
154
- export declare function follows(descriptor: ArtifactDescriptor, versions: readonly ArtifactDescriptor[]): boolean;
171
+ export declare function follows(descriptor: PresentedArtifact, versions: readonly PresentedArtifact[]): boolean;
155
172
  /** The number of user messages in the current native view, used to retain a manual close until the next turn. */
156
173
  export declare function artifactTurn(view: ConversationView | undefined): number;
157
174
  //# sourceMappingURL=chat-artifacts.d.ts.map
@@ -1,3 +1,5 @@
1
+ import { parseTaskArtifact, parseTaskSubject, taskSubjectKey } from './task-artifacts.js';
2
+ export { createTaskArtifact, parseTaskArtifact, parseTaskSubject, taskSubjectKey } from './task-artifacts.js';
1
3
  /*
2
4
  * Artifacts in the chat: substantial content (a document, an HTML page, an SVG, code, a canvas) that the transcript shows as a
3
5
  * card and the host opens in its own panel. The descriptor and `parseArtifact` mirror `@hachej/boring-agent/artifacts` on purpose: the UI
@@ -5,9 +7,12 @@
5
7
  * `test/contracts/pi-chat-source.test.mjs` checks that they accept and refuse the same values.
6
8
  */
7
9
  export const ARTIFACT_TYPES = ['markdown', 'html', 'svg', 'code', 'canvas'];
8
- /** What identifies an artifact across its versions: its id, or for a presented file its path. */
9
- export const artifactKey = (artifact) => artifact.id ?? artifact.target.resource.path;
10
+ export const artifactKey = (artifact) => 'subject' in artifact ? taskSubjectKey(artifact.subject) : artifact.id ?? artifact.target.resource.path;
11
+ /** File revisions are retained history; a task reference has no invented revision. */
12
+ export const artifactVersionKey = (artifact) => JSON.stringify([artifactKey(artifact), artifact.type === 'task' ? null : artifact.revision]);
13
+ export function parsePresentedArtifact(value) { return parseArtifact(value) ?? parseTaskArtifact(value); }
10
14
  export const PRESENT_TOOL = 'present';
15
+ const presentationKey = (value) => 'subject' in value ? taskSubjectKey(value.subject) : JSON.stringify(['file', presentPath(value.path)]);
11
16
  /** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */
12
17
  export const presentPath = (path) => path.replace(/^\.\//, '');
13
18
  /**
@@ -26,9 +31,13 @@ export function pendingPresents(view) {
26
31
  const item = message;
27
32
  if (item.role === 'assistant' && Array.isArray(item.content)) {
28
33
  for (const part of item.content) {
29
- const path = part.arguments?.['path'];
30
- if (part.type === 'toolCall' && part.name === PRESENT_TOOL && typeof part.id === 'string' && typeof path === 'string') {
31
- calls.set(part.id, { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id, path: presentPath(path) });
34
+ const path = part.arguments?.['path'], subject = parseTaskSubject(part.arguments);
35
+ if (part.type === 'toolCall' && part.name === PRESENT_TOOL && typeof part.id === 'string') {
36
+ const binding = { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id };
37
+ if (typeof path === 'string')
38
+ calls.set(part.id, { ...binding, path: presentPath(path) });
39
+ else if (subject)
40
+ calls.set(part.id, { ...binding, subject });
32
41
  }
33
42
  }
34
43
  }
@@ -92,7 +101,7 @@ export function detectArtifact(call, result, detect) {
92
101
  return undefined;
93
102
  if (detect) {
94
103
  try {
95
- const found = parseArtifact(detect(call, result));
104
+ const found = parsePresentedArtifact(detect(call, result));
96
105
  if (found)
97
106
  return found;
98
107
  }
@@ -101,11 +110,11 @@ export function detectArtifact(call, result, detect) {
101
110
  if (call.name !== PRESENT_TOOL)
102
111
  return undefined;
103
112
  const text = textOf(result);
104
- if (!text.includes('"boring.artifact"'))
113
+ if (!text.includes('"boring.artifact"') && !text.includes('"boring.task"'))
105
114
  return undefined;
106
115
  try {
107
116
  const value = JSON.parse(text);
108
- return isObject(value) ? parseArtifact(value['artifact']) : undefined;
117
+ return isObject(value) ? parsePresentedArtifact(value['artifact']) : undefined;
109
118
  }
110
119
  catch {
111
120
  return undefined;
@@ -113,12 +122,18 @@ export function detectArtifact(call, result, detect) {
113
122
  }
114
123
  /** The title a still-running `present` call announces in its streamed arguments: the file name. */
115
124
  export function pendingTitle(call) {
125
+ const subject = parseTaskSubject(call.arguments);
126
+ if (subject)
127
+ return 'task' in subject ? subject.task : subject.tasks.filter === 'mine' ? 'My requests' : subject.tasks.filter === 'waiting-on-me' ? 'Waiting on me' : 'Tasks';
116
128
  const path = call.arguments['path'];
117
129
  const title = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : undefined;
118
130
  return title?.trim() ? title.trim().slice(0, 200) : undefined;
119
131
  }
120
132
  /** What a still-running `present` call will show: the file path. */
121
133
  export function pendingId(call) {
134
+ const subject = parseTaskSubject(call.arguments);
135
+ if (subject)
136
+ return taskSubjectKey(subject);
122
137
  const path = call.arguments['path'];
123
138
  return typeof path === 'string' && path ? path.replace(/^(\.\/)+/, '') : undefined;
124
139
  }
@@ -155,8 +170,8 @@ export function createPresentAcks(host) {
155
170
  const mounting = new Map();
156
171
  const wait = host.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
157
172
  const now = host.now ?? (() => Date.now());
158
- /** The last mount report: the file on screen and its revision. */
159
- let lastShown;
173
+ /** Independent mounted subjects retain their own evidence until that viewer hides or fails. */
174
+ const mounted = new Map();
160
175
  /** Sends one answer; returns the server's outcome kind, or undefined when it was not sent. Retries while the record is not written yet. */
161
176
  async function send(id, body) {
162
177
  const until = now() + 3000;
@@ -176,8 +191,8 @@ export function createPresentAcks(host) {
176
191
  }
177
192
  }
178
193
  /** Confirms a shown revision. The call keeps its entry while the answer is in flight, so a mount during the flight still confirms it. */
179
- async function confirm(id, path, revision) {
180
- const kind = await send(id, { kind: 'opened', instanceId: host.instanceId, path, revision });
194
+ async function confirm(id, view) {
195
+ const kind = await send(id, { kind: 'opened', instanceId: host.instanceId, ...view });
181
196
  // Only an accepted or conflicting answer ends the call. A denial (another revision), a failed request or an unrecorded call keeps it waiting for a later mount or a live-call check.
182
197
  if (kind === 'answered' || kind === 'conflict')
183
198
  mounting.delete(id);
@@ -191,24 +206,24 @@ export function createPresentAcks(host) {
191
206
  return {
192
207
  /** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */
193
208
  shown(view) {
194
- for (const [id, path] of mounting)
195
- if (path === view.path)
196
- void confirm(id, path, view.revision);
197
- lastShown = view;
209
+ for (const [id, call] of mounting)
210
+ if (presentationKey(call) === presentationKey(view))
211
+ void confirm(id, view);
212
+ mounted.set(presentationKey(view), view);
198
213
  },
199
214
  /** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */
200
215
  failed(view) {
201
- if (lastShown?.path === view.path)
202
- lastShown = undefined;
216
+ mounted.delete(presentationKey(view));
203
217
  const reason = view.reason.trim().slice(0, 200) || 'the view failed';
204
- for (const [id, path] of mounting)
205
- if (path === view.path)
218
+ for (const [id, call] of mounting)
219
+ if (presentationKey(call) === presentationKey(view))
206
220
  void refuse(id, reason);
207
221
  },
208
222
  /** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */
209
223
  hidden(view) {
210
- if (lastShown?.path === view.path && lastShown.revision === view.revision)
211
- lastShown = undefined;
224
+ const shown = mounted.get(presentationKey(view));
225
+ if (shown && ('subject' in view || ('revision' in shown && shown.revision === view.revision)))
226
+ mounted.delete(presentationKey(view));
212
227
  },
213
228
  /** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */
214
229
  consider(calls, context) {
@@ -227,15 +242,17 @@ export function createPresentAcks(host) {
227
242
  void send(call.id, { kind: 'refused', reason: 'closed by user' });
228
243
  else if (context.unsaved)
229
244
  void send(call.id, { kind: 'refused', reason: 'unsaved changes' });
230
- else if (context.narrow)
245
+ else if (context.narrow && !('subject' in call))
231
246
  void send(call.id, { kind: 'refused', reason: 'narrow screen' });
232
247
  else {
233
- mounting.set(call.id, call.path);
234
- if (lastShown?.path === call.path && context.showing(call.path))
235
- void confirm(call.id, call.path, lastShown.revision);
236
- else if (context.autoOpen && !host.open(call.path)) {
248
+ mounting.set(call.id, call);
249
+ const showing = 'subject' in call ? context.showingTask?.(call.subject) : context.showing(call.path);
250
+ const shown = mounted.get(presentationKey(call));
251
+ if (shown && showing)
252
+ void confirm(call.id, shown);
253
+ else if (context.autoOpen && ('subject' in call ? host.openTask?.(call.subject) === false : !host.open(call.path))) {
237
254
  mounting.delete(call.id);
238
- void send(call.id, { kind: 'refused', reason: 'unsaved changes' });
255
+ void send(call.id, { kind: 'refused', reason: 'subject' in call ? 'task viewer unavailable' : 'unsaved changes' });
239
256
  }
240
257
  }
241
258
  }
@@ -254,7 +271,7 @@ export function recogniseArtifact(call, result, running, detect) {
254
271
  if (!artifact)
255
272
  return undefined;
256
273
  // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.
257
- return { state: 'ready', id: artifactKey(artifact), title: artifact.title, path: artifact.target.resource.path, own: detectArtifact(call, result) !== undefined, descriptor: artifact };
274
+ return { state: 'ready', id: artifactKey(artifact), title: artifact.title, path: artifact.type === 'task' ? undefined : artifact.target.resource.path, own: detectArtifact(call, result) !== undefined, descriptor: artifact };
258
275
  }
259
276
  if (running && call.name === PRESENT_TOOL)
260
277
  return { state: 'presenting', id: pendingId(call), title: pendingTitle(call), path: undefined, own: true, descriptor: undefined };
@@ -266,7 +283,8 @@ export function artifactVersions(view, detect) {
266
283
  }
267
284
  /** Whether this descriptor is the newest known revision of its artifact, never an ordinal comparison. */
268
285
  export function follows(descriptor, versions) {
269
- return descriptor.revision === versions.find(version => artifactKey(version) === artifactKey(descriptor))?.revision;
286
+ const newest = versions.find(version => artifactKey(version) === artifactKey(descriptor));
287
+ return newest !== undefined && artifactVersionKey(descriptor) === artifactVersionKey(newest);
270
288
  }
271
289
  /** The number of user messages in the current native view, used to retain a manual close until the next turn. */
272
290
  export function artifactTurn(view) {
@@ -1,5 +1,5 @@
1
1
  import type { ChatController } from './native-chat.js';
2
- import type { ArtifactDescriptor, ArtifactDetect, PresentAcksHost } from './chat-view.js';
2
+ import type { PresentedArtifact, ArtifactDetect, PresentAcksHost, PresentationShown, PresentationFailed, TaskSubject } from './chat-view.js';
3
3
  export { useChatDraft } from './chat-react-draft.js';
4
4
  export { useControllerPerConversation } from './chat-react-controller.js';
5
5
  export type { ControllerPerConversationOptions, ConversationControllerState } from './chat-react-controller.js';
@@ -17,19 +17,15 @@ export declare function useRunNotifications(controller: RunNotificationSource, s
17
17
  /** Selects the authorized native view using the controller's own cached snapshot and subscription. Borrows its lifetime. */
18
18
  export declare function useChatView<T>(controller: ChatController | undefined, select: (view: ReturnType<ChatController['getSnapshot']>['view']) => T): T;
19
19
  export interface ArtifactSelection {
20
- readonly descriptor: ArtifactDescriptor;
20
+ readonly descriptor: PresentedArtifact;
21
21
  readonly follow: boolean;
22
22
  }
23
- export interface ArtifactShown {
24
- readonly path: string;
25
- readonly revision: string;
23
+ export type ArtifactShown = PresentationShown & {
26
24
  readonly hidden?: true;
27
- }
28
- export interface ArtifactFailed {
29
- readonly path: string;
30
- readonly revision: string;
31
- readonly reason: string;
32
- }
25
+ };
26
+ export type ArtifactFailed = PresentationFailed & {
27
+ readonly revision?: string;
28
+ };
33
29
  export interface ArtifactFollowOptions {
34
30
  readonly controller: ChatController | undefined;
35
31
  readonly conversationId?: string | undefined;
@@ -42,17 +38,19 @@ export interface ArtifactFollowOptions {
42
38
  readonly narrow?: (() => boolean) | undefined;
43
39
  readonly showing: (path: string) => boolean;
44
40
  readonly openPresent: (path: string) => boolean;
41
+ readonly openTask?: ((subject: TaskSubject) => boolean) | undefined;
42
+ readonly showingTask?: ((subject: TaskSubject) => boolean) | undefined;
45
43
  readonly answer?: PresentAcksHost['answer'] | undefined;
46
44
  }
47
45
  /** One version/follow/auto-open and present-ack rule for every chat surface. Mount evidence must come from the viewer. */
48
46
  export declare function useArtifactFollow(options: ArtifactFollowOptions): {
49
- versions: ArtifactDescriptor[];
47
+ versions: PresentedArtifact[];
50
48
  turn: number;
51
- known: ArtifactDescriptor[];
52
- shown: ArtifactDescriptor | undefined;
53
- open: (descriptor: ArtifactDescriptor) => void;
54
- isOpen: (descriptor: ArtifactDescriptor) => boolean;
55
- select: (version: ArtifactDescriptor | "latest") => void;
49
+ known: PresentedArtifact[];
50
+ shown: PresentedArtifact | undefined;
51
+ open: (descriptor: PresentedArtifact) => void;
52
+ isOpen: (descriptor: PresentedArtifact) => boolean;
53
+ select: (version: PresentedArtifact | "latest") => void;
56
54
  recordClosed: () => void;
57
55
  onShown: (shown: ArtifactShown) => void;
58
56
  onFailed: (failed: ArtifactFailed) => void;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  /** Opt-in UI projection over the native controller; owns no Pi execution or controller lifetime. */
3
3
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
4
- import { artifactKey, artifactTurn, artifactVersions, createPresentAcks, follows, pendingPresents, presentPath } from './chat-view.js';
4
+ import { artifactKey, artifactVersionKey, artifactTurn, artifactVersions, createPresentAcks, follows, pendingPresents, presentPath } from './chat-view.js';
5
5
  export { useChatDraft } from './chat-react-draft.js';
6
6
  export { useControllerPerConversation } from './chat-react-controller.js';
7
7
  export { chunkKeys, useRowChunks, useTranscript, useTranscriptWindow, PAGE_FIRST, PAGE_MORE, CHUNK } from './chat-react-transcript.js';
@@ -43,12 +43,13 @@ export function useArtifactFollow(options) {
43
43
  const acks = useMemo(() => createPresentAcks({ instanceId,
44
44
  answer: (id, body) => current() ? latest.current.answer?.(id, body) : undefined,
45
45
  open: path => current() && latest.current.openPresent(path),
46
+ openTask: subject => current() && latest.current.openTask?.(subject) !== false,
46
47
  }), [current, instanceId]);
47
48
  useLayoutEffect(() => { binding.active = true; return () => { binding.active = false; }; }, [binding]);
48
49
  useEffect(() => {
49
50
  if (!controller || conversationId === undefined)
50
51
  return;
51
- const keys = versions.map(version => JSON.stringify([artifactKey(version), version.revision]));
52
+ const keys = versions.map(artifactVersionKey);
52
53
  const known = seen.current.get(conversationId);
53
54
  if (!known) {
54
55
  seen.current.set(conversationId, new Set(keys));
@@ -66,24 +67,24 @@ export function useArtifactFollow(options) {
66
67
  return;
67
68
  acks.consider(calls, { closedThisTurn: closedInTurn.current.get(conversationId) === turn,
68
69
  unsaved: latest.current.unsaved?.() === true, narrow: latest.current.narrow?.() === true, autoOpen,
69
- showing: path => latest.current.showing(path) });
70
+ showing: path => latest.current.showing(path), showingTask: subject => latest.current.showingTask?.(subject) === true });
70
71
  }, [acks, calls, conversationId, turn, autoOpen, options.answer]);
71
72
  const onShown = useCallback((shown) => {
72
73
  if (!current())
73
74
  return;
74
- const report = { path: presentPath(shown.path), revision: shown.revision };
75
+ const report = 'subject' in shown ? { subject: shown.subject } : { path: presentPath(shown.path), revision: shown.revision };
75
76
  if (shown.hidden)
76
77
  acks.hidden(report);
77
78
  else
78
79
  acks.shown(report);
79
80
  }, [acks, current]);
80
81
  const onFailed = useCallback((failed) => { if (current())
81
- acks.failed({ ...failed, path: presentPath(failed.path) }); }, [acks, current]);
82
+ acks.failed('subject' in failed ? failed : { ...failed, path: presentPath(failed.path) }); }, [acks, current]);
82
83
  const recordClosed = useCallback(() => { if (conversationId !== undefined)
83
84
  closedInTurn.current.set(conversationId, turn); }, [conversationId, turn]);
84
85
  const open = useCallback((descriptor) => latest.current.onOpen({ descriptor, follow: follows(descriptor, versions) }), [versions]);
85
86
  const isOpen = useCallback((descriptor) => Boolean(active && artifactKey(active.descriptor) === artifactKey(descriptor) &&
86
- (active.follow ? follows(descriptor, versions) : descriptor.revision === active.descriptor.revision)), [active, versions]);
87
+ (active.follow ? follows(descriptor, versions) : artifactVersionKey(descriptor) === artifactVersionKey(active.descriptor))), [active, versions]);
87
88
  const known = useMemo(() => active ? versions.filter(version => artifactKey(version) === artifactKey(active.descriptor)) : [], [active, versions]);
88
89
  const shown = active ? active.follow ? known[0] ?? active.descriptor : active.descriptor : undefined;
89
90
  const select = useCallback((version) => {
@@ -0,0 +1,22 @@
1
+ /** A host-owned task reference, not a file, execution task, permission or mutation. */
2
+ export type TaskSubject = {
3
+ readonly task: string;
4
+ } | {
5
+ readonly tasks: {
6
+ readonly filter: 'all' | 'mine' | 'waiting-on-me';
7
+ };
8
+ };
9
+ export interface TaskArtifactDescriptor {
10
+ readonly schema: 'boring.task';
11
+ readonly version: 1;
12
+ readonly type: 'task';
13
+ readonly title: string;
14
+ readonly subject: TaskSubject;
15
+ }
16
+ /** Strict, detached reference parsing; the host still authenticates and resolves the task. */
17
+ export declare function parseTaskSubject(value: unknown): TaskSubject | undefined;
18
+ /** The browser and server use the same exact subject identity for presentation acknowledgements. */
19
+ export declare function taskSubjectKey(subject: TaskSubject): string;
20
+ export declare function parseTaskArtifact(value: unknown): TaskArtifactDescriptor | undefined;
21
+ export declare function createTaskArtifact(subject: TaskSubject): TaskArtifactDescriptor;
22
+ //# sourceMappingURL=task-artifacts.d.ts.map
@@ -0,0 +1,34 @@
1
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+ const only = (value, keys) => Object.keys(value).every(key => keys.includes(key));
3
+ /** Strict, detached reference parsing; the host still authenticates and resolves the task. */
4
+ export function parseTaskSubject(value) {
5
+ if (!object(value))
6
+ return undefined;
7
+ if (only(value, ['task']) && typeof value['task'] === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value['task']))
8
+ return { task: value['task'] };
9
+ const tasks = value['tasks'];
10
+ if (!only(value, ['tasks']) || !object(tasks) || !only(tasks, ['filter']))
11
+ return undefined;
12
+ const filter = tasks['filter'];
13
+ return filter === 'all' || filter === 'mine' || filter === 'waiting-on-me' ? { tasks: { filter } } : undefined;
14
+ }
15
+ /** The browser and server use the same exact subject identity for presentation acknowledgements. */
16
+ export function taskSubjectKey(subject) {
17
+ return 'task' in subject ? JSON.stringify(['task', subject.task]) : JSON.stringify(['tasks', subject.tasks.filter]);
18
+ }
19
+ export function parseTaskArtifact(value) {
20
+ if (!object(value) || !only(value, ['schema', 'version', 'type', 'title', 'subject']))
21
+ return undefined;
22
+ if (value['schema'] !== 'boring.task' || value['version'] !== 1 || value['type'] !== 'task')
23
+ return undefined;
24
+ const title = value['title'], subject = parseTaskSubject(value['subject']);
25
+ if (typeof title !== 'string' || !title.trim() || title.length > 200 || !subject)
26
+ return undefined;
27
+ return { schema: 'boring.task', version: 1, type: 'task', title, subject };
28
+ }
29
+ export function createTaskArtifact(subject) {
30
+ const parsed = parseTaskSubject(subject);
31
+ if (!parsed)
32
+ throw new TypeError('Invalid task subject');
33
+ return { schema: 'boring.task', version: 1, type: 'task', title: 'task' in parsed ? parsed.task : parsed.tasks.filter === 'mine' ? 'My requests' : parsed.tasks.filter === 'waiting-on-me' ? 'Waiting on me' : 'Tasks', subject: parsed };
34
+ }
@@ -0,0 +1,83 @@
1
+ /** Business requests supplied by the host, not Pi execution tasks. No fetching, mutation or authorization lives here. */
2
+ export declare const TASK_STATES: readonly ["waiting", "working", "open", "done", "closed"];
3
+ export type TaskState = typeof TASK_STATES[number];
4
+ export type TaskFilter = 'all' | 'mine' | 'waiting-on-me';
5
+ export interface TaskQuestion {
6
+ readonly id: string;
7
+ readonly text: string;
8
+ /** Host-supplied display identity and timestamp for the question. */
9
+ readonly askedBy?: string;
10
+ readonly askedAt?: string;
11
+ readonly options?: readonly (string | {
12
+ readonly label: string;
13
+ readonly value: string;
14
+ })[];
15
+ readonly answer?: string;
16
+ /** Display identity supplied by the host, not inferred from the current viewer. */
17
+ readonly answeredBy?: string;
18
+ /** Optional host answer timestamp; newest answers first, otherwise latest source entries first. */
19
+ readonly answeredAt?: string;
20
+ }
21
+ export interface TaskFile {
22
+ /** Authorized workspace path supplied by the host adapter; never derived from the task id. */
23
+ readonly path: string;
24
+ readonly name?: string;
25
+ /** Presentation metadata, not permission to execute or load a file. */
26
+ readonly kind?: 'markdown' | 'mockup' | 'capture';
27
+ }
28
+ export interface TaskHistory {
29
+ readonly id?: string;
30
+ readonly at?: string;
31
+ readonly actor?: string;
32
+ readonly note: string;
33
+ }
34
+ export interface TaskRecord {
35
+ readonly id: string;
36
+ readonly number?: number | string;
37
+ readonly title: string;
38
+ readonly state: TaskState;
39
+ /** Host wording such as “À essayer”, independent of the five board columns. */
40
+ readonly status?: string;
41
+ /** The host adapter resolves the request's author; never inferred from an assignee. */
42
+ readonly requesterId?: string;
43
+ /** Host-formatted display metadata; identities here never determine ownership or filtering. */
44
+ readonly requester?: string;
45
+ readonly refiner?: string;
46
+ readonly age?: string;
47
+ /** Native viewer-relative projection, not inferred from waiting_on.who. */
48
+ readonly waiting_on_you?: boolean;
49
+ readonly waiting_on?: {
50
+ readonly who: string;
51
+ readonly question?: string;
52
+ readonly type?: 'builder' | 'person' | 'other';
53
+ };
54
+ readonly risk?: string;
55
+ readonly spec?: string;
56
+ readonly screen?: string;
57
+ readonly capture?: string;
58
+ /** Number of captures attached to the request, independent of the legacy capture description. */
59
+ readonly captureCount?: number;
60
+ /** Authorized workspace folder supplied by the host; never synthesized from the task reference. */
61
+ readonly folder?: string;
62
+ readonly questions?: readonly TaskQuestion[];
63
+ readonly files?: readonly TaskFile[];
64
+ readonly history?: readonly TaskHistory[];
65
+ readonly updated_at?: string;
66
+ readonly created_at?: string;
67
+ }
68
+ export type TaskSemanticStatus = 'waiting-on-viewer' | 'waiting-on-builder' | 'high-risk' | 'none';
69
+ /** Single-pill presentation precedence: viewer, explicit builder, high risk. No workflow or authority is changed. */
70
+ export declare function deriveTaskStatus(task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'>): TaskSemanticStatus;
71
+ /** Confirmation wording is explicit host context, never inferred from a question, title or free-form status. */
72
+ export declare function taskStatusLabel(status: TaskSemanticStatus, locale?: 'en' | 'fr', viewerAction?: 'question' | 'confirmation'): string | undefined;
73
+ export interface TaskSelection {
74
+ readonly filter?: TaskFilter;
75
+ readonly currentUser?: string;
76
+ readonly limit?: number;
77
+ }
78
+ /** Newest first, stable at equal/absent dates. Filtering is presentation, not an access boundary. */
79
+ export declare function selectTasks(tasks: readonly TaskRecord[], { filter, currentUser, limit }?: TaskSelection): readonly TaskRecord[];
80
+ export declare function taskReference(task: TaskRecord): string;
81
+ export declare function taskFromUrl(url: string): string | null;
82
+ export declare function taskUrl(url: string, id: string | null): string;
83
+ //# sourceMappingURL=task-board.d.ts.map
@@ -0,0 +1,42 @@
1
+ /** Business requests supplied by the host, not Pi execution tasks. No fetching, mutation or authorization lives here. */
2
+ export const TASK_STATES = ['waiting', 'working', 'open', 'done', 'closed'];
3
+ /** Single-pill presentation precedence: viewer, explicit builder, high risk. No workflow or authority is changed. */
4
+ export function deriveTaskStatus(task) {
5
+ if (task.waiting_on_you === true)
6
+ return 'waiting-on-viewer';
7
+ if (task.waiting_on?.type === 'builder')
8
+ return 'waiting-on-builder';
9
+ if (task.risk === 'high')
10
+ return 'high-risk';
11
+ return 'none';
12
+ }
13
+ /** Confirmation wording is explicit host context, never inferred from a question, title or free-form status. */
14
+ export function taskStatusLabel(status, locale = 'en', viewerAction = 'question') {
15
+ switch (status) {
16
+ case 'waiting-on-viewer': return locale === 'fr' ? viewerAction === 'confirmation' ? 'À confirmer' : 'À vous' : 'Waiting on you';
17
+ case 'waiting-on-builder': return locale === 'fr' ? "Chez l'équipe" : 'Waiting on the builder';
18
+ case 'high-risk': return locale === 'fr' ? 'Risque élevé' : 'High risk';
19
+ default: return undefined;
20
+ }
21
+ }
22
+ /** Newest first, stable at equal/absent dates. Filtering is presentation, not an access boundary. */
23
+ export function selectTasks(tasks, { filter = 'all', currentUser, limit } = {}) {
24
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0))
25
+ throw new RangeError('Task limit must be a nonnegative integer');
26
+ const date = (task) => { const value = Date.parse(task.updated_at ?? task.created_at ?? ''); return Number.isFinite(value) ? value : 0; };
27
+ const selected = tasks.filter(task => filter === 'mine' ? currentUser !== undefined && task.requesterId === currentUser : filter === 'waiting-on-me' ? task.state === 'waiting' && task.waiting_on_you === true : true)
28
+ .sort((a, b) => date(b) - date(a));
29
+ return limit === undefined ? selected : selected.slice(0, limit);
30
+ }
31
+ export function taskReference(task) {
32
+ return task.number === undefined ? task.id : String(task.number).startsWith('D-') ? String(task.number) : `D-${task.number}`;
33
+ }
34
+ export function taskFromUrl(url) { return new URL(url, 'https://task.invalid').searchParams.get('task'); }
35
+ export function taskUrl(url, id) {
36
+ const next = new URL(url, 'https://task.invalid');
37
+ if (id === null)
38
+ next.searchParams.delete('task');
39
+ else
40
+ next.searchParams.set('task', id);
41
+ return next.href;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -147,10 +147,14 @@
147
147
  "./mark-store": {
148
148
  "types": "./dist/mark-store.d.ts",
149
149
  "import": "./dist/mark-store.js"
150
+ },
151
+ "./task-board": {
152
+ "types": "./dist/task-board.d.ts",
153
+ "import": "./dist/task-board.js"
150
154
  }
151
155
  },
152
156
  "peerDependencies": {
153
- "@hachej/boring-files": "^0.4.29",
157
+ "@hachej/boring-files": "^0.4.30",
154
158
  "@earendil-works/pi-durable": "~1.4.0",
155
159
  "react": "^19.0.0",
156
160
  "react-dom": "^19.0.0",