letagents 0.12.21 → 0.12.23

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.
@@ -0,0 +1,518 @@
1
+ <template>
2
+ <section class="rooms-directory" aria-label="Rooms">
3
+ <header class="rooms-heading">
4
+ <div>
5
+ <p class="rooms-parent">
6
+ <span aria-hidden="true">⌂</span> {{ parentLabel }}
7
+ </p>
8
+ <h2>Rooms</h2>
9
+ <p class="rooms-intro">
10
+ Give a task or topic its own conversation. Bring the outcome back
11
+ here.
12
+ </p>
13
+ </div>
14
+ <div class="rooms-heading-actions">
15
+ <button
16
+ v-if="canRefresh"
17
+ class="rooms-icon-button"
18
+ type="button"
19
+ aria-label="Refresh rooms"
20
+ @click="emit('refresh')"
21
+ >
22
+ <svg viewBox="0 0 24 24" aria-hidden="true">
23
+ <path
24
+ d="M20 7v5h-5M4 17v-5h5M6 7a7 7 0 0 1 12-1l2 3M4 15l2 3a7 7 0 0 0 12-1"
25
+ />
26
+ </svg>
27
+ </button>
28
+ <button
29
+ ref="newRoomButton"
30
+ class="rooms-button rooms-button-primary"
31
+ type="button"
32
+ :aria-expanded="composing"
33
+ :aria-controls="`${uid}-create`"
34
+ @click="toggleComposer"
35
+ >
36
+ <span aria-hidden="true">+</span> New room
37
+ </button>
38
+ </div>
39
+ </header>
40
+
41
+ <Transition name="rooms-reveal">
42
+ <section
43
+ v-if="composing"
44
+ :id="`${uid}-create`"
45
+ class="rooms-composer"
46
+ aria-label="New room"
47
+ @keydown.esc.stop.prevent="closeComposer"
48
+ >
49
+ <div class="rooms-composer-heading">
50
+ <div>
51
+ <h3>Make space for focused work</h3>
52
+ <p>The conversation stays connected to {{ parentLabel }}.</p>
53
+ </div>
54
+ <button
55
+ class="rooms-icon-button"
56
+ type="button"
57
+ aria-label="Cancel new room"
58
+ :disabled="busy"
59
+ @click="closeComposer"
60
+ >
61
+ <span aria-hidden="true">×</span>
62
+ </button>
63
+ </div>
64
+ <div class="rooms-mode" aria-label="Room purpose">
65
+ <button
66
+ type="button"
67
+ :aria-pressed="creationMode === 'topic'"
68
+ :disabled="busy"
69
+ @click="setCreationMode('topic')"
70
+ >
71
+ For a topic
72
+ </button>
73
+ <button
74
+ type="button"
75
+ :aria-pressed="creationMode === 'task'"
76
+ :disabled="busy"
77
+ @click="setCreationMode('task')"
78
+ >
79
+ From a task
80
+ </button>
81
+ </div>
82
+ <form
83
+ v-if="creationMode === 'topic'"
84
+ class="rooms-topic-form"
85
+ @submit.prevent="createTopic"
86
+ >
87
+ <label :for="`${uid}-title`">Room name</label>
88
+ <div class="rooms-input-action">
89
+ <input
90
+ :id="`${uid}-title`"
91
+ ref="titleInput"
92
+ v-model="topicTitle"
93
+ placeholder="e.g. Plan the next release"
94
+ autocomplete="off"
95
+ :disabled="busy"
96
+ required
97
+ />
98
+ <button
99
+ class="rooms-button rooms-button-primary"
100
+ type="submit"
101
+ :disabled="busy || !topicTitle.trim()"
102
+ >
103
+ {{ busy ? 'Creating…' : 'Create and open'
104
+ }}<span v-if="!busy" aria-hidden="true">↗</span>
105
+ </button>
106
+ </div>
107
+ <p class="rooms-hint">
108
+ A shared conversation, with its own agents and history. No task
109
+ required.
110
+ </p>
111
+ </form>
112
+ <div v-else class="rooms-task-picker">
113
+ <label class="rooms-search"
114
+ ><svg viewBox="0 0 24 24" aria-hidden="true">
115
+ <circle cx="10.5" cy="10.5" r="6.5" />
116
+ <path d="m16 16 4 4" /></svg
117
+ ><input
118
+ ref="taskSearchInput"
119
+ v-model="taskQuery"
120
+ type="search"
121
+ aria-label="Find a task"
122
+ placeholder="Find a task…"
123
+ :disabled="busy"
124
+ /></label>
125
+ <div
126
+ v-if="visibleTasks.length"
127
+ class="rooms-task-list"
128
+ aria-label="Choose a task"
129
+ >
130
+ <button
131
+ v-for="task in visibleTasks"
132
+ :key="task.id"
133
+ type="button"
134
+ :aria-pressed="selectedTaskId === task.id"
135
+ :disabled="busy"
136
+ @click="selectedTaskId = task.id"
137
+ >
138
+ <span class="rooms-task-radio" aria-hidden="true"></span
139
+ ><span
140
+ ><strong>{{ task.title }}</strong
141
+ ><small>{{
142
+ task.roomId
143
+ ? task.roomClosed
144
+ ? 'Room closed · view its result'
145
+ : 'Room already open'
146
+ : taskStatusLabel(task.status)
147
+ }}</small></span
148
+ ><span v-if="selectedTaskId === task.id" aria-hidden="true"
149
+ >✓</span
150
+ >
151
+ </button>
152
+ </div>
153
+ <p v-else class="rooms-task-empty">
154
+ {{
155
+ taskQuery
156
+ ? 'No tasks match your search.'
157
+ : 'No open tasks yet. Add a task on the board, or start with a topic.'
158
+ }}
159
+ </p>
160
+ <div class="rooms-task-footer">
161
+ <p>
162
+ {{
163
+ selectedTask?.roomId
164
+ ? 'This task already has a room. Continue the conversation there.'
165
+ : 'The task and this conversation stay linked.'
166
+ }}
167
+ </p>
168
+ <button
169
+ class="rooms-button rooms-button-primary"
170
+ type="button"
171
+ :disabled="!selectedTask || busy"
172
+ @click="createTask"
173
+ >
174
+ {{
175
+ busy
176
+ ? 'Opening…'
177
+ : selectedTask?.roomId
178
+ ? 'Open existing room'
179
+ : 'Create and open'
180
+ }}<span v-if="!busy" aria-hidden="true">↗</span>
181
+ </button>
182
+ </div>
183
+ </div>
184
+ </section>
185
+ </Transition>
186
+ <div v-if="error" class="rooms-error" role="alert">
187
+ <p>{{ error }}</p>
188
+ <button
189
+ v-if="createdRoom"
190
+ class="rooms-button"
191
+ type="button"
192
+ :disabled="busy"
193
+ @click="emit('retryOpen')"
194
+ >
195
+ Open {{ createdRoom.title }} <span aria-hidden="true">↗</span>
196
+ </button>
197
+ </div>
198
+
199
+ <div class="rooms-toolbar">
200
+ <div class="rooms-filters" aria-label="Room status">
201
+ <button
202
+ type="button"
203
+ :aria-pressed="!showClosed"
204
+ @click="showClosed = false"
205
+ >
206
+ Open <span>{{ rooms.filter((room) => !room.closed).length }}</span>
207
+ </button>
208
+ <button
209
+ type="button"
210
+ :aria-pressed="showClosed"
211
+ @click="showClosed = true"
212
+ >
213
+ Closed <span>{{ rooms.filter((room) => room.closed).length }}</span>
214
+ </button>
215
+ </div>
216
+ <label class="rooms-search"
217
+ ><svg viewBox="0 0 24 24" aria-hidden="true">
218
+ <circle cx="10.5" cy="10.5" r="6.5" />
219
+ <path d="m16 16 4 4" /></svg
220
+ ><input
221
+ v-model="query"
222
+ type="search"
223
+ aria-label="Search rooms"
224
+ placeholder="Search rooms…"
225
+ /></label>
226
+ </div>
227
+ <div class="rooms-list-caption">
228
+ <span>{{
229
+ showClosed
230
+ ? 'Finished conversations & outcomes'
231
+ : 'Conversations in this room'
232
+ }}</span
233
+ ><span>Newest first</span>
234
+ </div>
235
+ <div v-if="visibleRooms.length" class="rooms-list">
236
+ <article
237
+ v-for="room in visibleRooms"
238
+ :key="room.id"
239
+ class="rooms-entry"
240
+ :data-expanded="expandedId === room.id"
241
+ >
242
+ <div class="rooms-entry-row">
243
+ <button
244
+ class="rooms-entry-main"
245
+ type="button"
246
+ :aria-label="`${room.closed ? 'View result for' : 'Open'} ${room.title}`"
247
+ @click="
248
+ room.closed ? toggleDetails(room.id) : emit('open', room.id)
249
+ "
250
+ >
251
+ <span
252
+ class="rooms-entry-icon"
253
+ :data-kind="room.kind"
254
+ aria-hidden="true"
255
+ ><svg viewBox="0 0 24 24">
256
+ <template v-if="room.closed">
257
+ <path d="m6 12 4 4 8-9" />
258
+ </template>
259
+ <template v-else-if="room.kind === 'branch'">
260
+ <circle cx="7" cy="5" r="2" />
261
+ <circle cx="17" cy="6" r="2" />
262
+ <circle cx="7" cy="19" r="2" />
263
+ <path d="M7 7v10m10-9c0 6-10 2-10 7" />
264
+ </template>
265
+ <template v-else-if="room.kind === 'task'">
266
+ <rect x="5" y="4" width="14" height="17" rx="3" />
267
+ <path d="M9 4V2h6v2M9 12l2 2 4-5" />
268
+ </template>
269
+ <template v-else>
270
+ <path d="M20 11a8 8 0 0 1-8 8H5l-3 2 1-6a8 8 0 1 1 17-4Z" />
271
+ </template></svg
272
+ ></span>
273
+ <span class="rooms-entry-copy"
274
+ ><span class="rooms-entry-title">{{ room.title }}</span
275
+ ><span class="rooms-entry-description"
276
+ ><span class="rooms-mobile-kind"
277
+ >{{ roomKindLabel(room) }} · </span
278
+ >{{
279
+ room.description ||
280
+ (room.closed
281
+ ? 'No outcome recorded. The conversation is still available.'
282
+ : room.kind === 'branch'
283
+ ? 'Conversation for this Git branch'
284
+ : room.kind === 'task'
285
+ ? 'A dedicated conversation for this task'
286
+ : 'A separate space for this topic')
287
+ }}</span
288
+ ></span
289
+ >
290
+ <span class="rooms-entry-kind">{{ roomKindLabel(room) }}</span>
291
+ <time
292
+ class="rooms-entry-date"
293
+ :datetime="
294
+ (room.closed ? room.closedAt : room.createdAt) || undefined
295
+ "
296
+ >{{
297
+ roomDate(room.closed ? room.closedAt : room.createdAt)
298
+ }}</time
299
+ >
300
+ <span class="rooms-entry-arrow" aria-hidden="true">{{
301
+ room.closed ? '↓' : '↗'
302
+ }}</span>
303
+ </button>
304
+ <button
305
+ :id="`${uid}-toggle-${room.id}`"
306
+ class="rooms-icon-button rooms-details-toggle"
307
+ type="button"
308
+ :aria-label="`${expandedId === room.id ? 'Hide' : 'Show'} details for ${room.title}`"
309
+ :aria-expanded="expandedId === room.id"
310
+ :aria-controls="`${uid}-details-${room.id}`"
311
+ @click="toggleDetails(room.id)"
312
+ >
313
+ <svg viewBox="0 0 24 24" aria-hidden="true">
314
+ <path
315
+ :d="expandedId === room.id ? 'm7 14 5-5 5 5' : 'm7 10 5 5 5-5'"
316
+ />
317
+ </svg>
318
+ </button>
319
+ </div>
320
+ <Transition name="rooms-reveal">
321
+ <section
322
+ v-if="expandedId === room.id"
323
+ :id="`${uid}-details-${room.id}`"
324
+ class="rooms-entry-details"
325
+ :aria-label="`${room.title} details`"
326
+ >
327
+ <slot name="details" :room="room"
328
+ ><p>{{ room.description }}</p></slot
329
+ >
330
+ </section>
331
+ </Transition>
332
+ </article>
333
+ </div>
334
+ <div v-else class="rooms-empty">
335
+ <div class="rooms-empty-mark" aria-hidden="true">
336
+ <span></span><span></span><span></span>
337
+ </div>
338
+ <h3>
339
+ {{
340
+ query
341
+ ? 'No rooms match your search'
342
+ : showClosed
343
+ ? 'Outcomes will live here'
344
+ : 'A little room to focus'
345
+ }}
346
+ </h3>
347
+ <p>
348
+ {{
349
+ query
350
+ ? 'Try a room name, task, or branch.'
351
+ : showClosed
352
+ ? 'Close a room when the work is done. Its conversation and result stay available here.'
353
+ : 'Start a conversation for one task or idea, with space for the right people and agents.'
354
+ }}
355
+ </p>
356
+ <button
357
+ v-if="query"
358
+ class="rooms-button"
359
+ type="button"
360
+ @click="query = ''"
361
+ >
362
+ Clear search
363
+ </button>
364
+ <button
365
+ v-else-if="!showClosed"
366
+ class="rooms-button"
367
+ type="button"
368
+ @click="openComposer"
369
+ >
370
+ Create your first room <span aria-hidden="true">↗</span>
371
+ </button>
372
+ </div>
373
+ <details class="rooms-explainer">
374
+ <summary>How rooms work</summary>
375
+ <div>
376
+ <p>
377
+ <strong>One conversation per task or topic.</strong> People and agents
378
+ can join to work together, then record an outcome when the room
379
+ closes.
380
+ </p>
381
+ <p>
382
+ <strong>Still connected.</strong> New rooms share their final note
383
+ back here by default. Change this in a room’s settings.
384
+ </p>
385
+ <p>
386
+ <strong>Git branches have rooms too.</strong> These follow the branch
387
+ agents are working on. Creating a topic room does not create a Git
388
+ branch or a local checkout.
389
+ </p>
390
+ </div>
391
+ </details>
392
+ </section>
393
+ </template>
394
+
395
+ <script setup lang="ts">
396
+ import { computed, nextTick, ref, useId, watch } from 'vue'
397
+ import {
398
+ filterRooms,
399
+ roomDate,
400
+ roomKindLabel,
401
+ taskStatusLabel,
402
+ type DirectoryRoom,
403
+ type DirectoryTask,
404
+ } from './directory'
405
+
406
+ const props = defineProps<{
407
+ rooms: readonly DirectoryRoom[]
408
+ tasks: readonly DirectoryTask[]
409
+ parentLabel: string
410
+ busy?: boolean
411
+ error?: string | null
412
+ canRefresh?: boolean
413
+ initialTaskId?: string | null
414
+ createdRoom?: { id: string; title: string } | null
415
+ }>()
416
+ const emit = defineEmits<{
417
+ open: [id: string]
418
+ select: [id: string | null]
419
+ createTopic: [title: string]
420
+ createTask: [id: string]
421
+ refresh: []
422
+ retryOpen: []
423
+ }>()
424
+ const uid = useId()
425
+ const composing = ref(false)
426
+ const creationMode = ref<'topic' | 'task'>('topic')
427
+ const topicTitle = ref('')
428
+ const query = ref('')
429
+ const taskQuery = ref('')
430
+ const showClosed = ref(false)
431
+ const expandedId = ref<string | null>(null)
432
+ const selectedTaskId = ref<string | null>(null)
433
+ const newRoomButton = ref<HTMLButtonElement | null>(null)
434
+ const titleInput = ref<HTMLInputElement | null>(null)
435
+ const taskSearchInput = ref<HTMLInputElement | null>(null)
436
+ const visibleRooms = computed(() =>
437
+ filterRooms(props.rooms, showClosed.value, query.value),
438
+ )
439
+ const visibleTasks = computed(() =>
440
+ props.tasks.filter((task) =>
441
+ `${task.title} ${task.description}`
442
+ .toLocaleLowerCase()
443
+ .includes(taskQuery.value.trim().toLocaleLowerCase()),
444
+ ),
445
+ )
446
+ const selectedTask = computed(() =>
447
+ visibleTasks.value.find((task) => task.id === selectedTaskId.value),
448
+ )
449
+
450
+ watch(
451
+ () => props.initialTaskId,
452
+ (id) => {
453
+ if (!id) return
454
+ taskQuery.value = ''
455
+ selectedTaskId.value = id
456
+ creationMode.value = 'task'
457
+ composing.value = true
458
+ },
459
+ { immediate: true },
460
+ )
461
+ watch(visibleRooms, (rooms) => {
462
+ if (expandedId.value && !rooms.some((room) => room.id === expandedId.value)) {
463
+ expandedId.value = null
464
+ emit('select', null)
465
+ }
466
+ })
467
+ watch(
468
+ visibleTasks,
469
+ (tasks) => {
470
+ if (
471
+ selectedTaskId.value &&
472
+ !tasks.some((task) => task.id === selectedTaskId.value)
473
+ )
474
+ selectedTaskId.value = null
475
+ },
476
+ { flush: 'sync' },
477
+ )
478
+ async function openComposer() {
479
+ composing.value = true
480
+ await focusComposer()
481
+ }
482
+ async function focusComposer() {
483
+ await nextTick()
484
+ ;(creationMode.value === 'topic'
485
+ ? titleInput.value
486
+ : taskSearchInput.value
487
+ )?.focus()
488
+ }
489
+ function toggleComposer() {
490
+ if (composing.value) closeComposer()
491
+ else void openComposer()
492
+ }
493
+ async function closeComposer() {
494
+ if (props.busy) return
495
+ composing.value = false
496
+ await nextTick()
497
+ newRoomButton.value?.focus()
498
+ }
499
+ async function setCreationMode(mode: 'topic' | 'task') {
500
+ creationMode.value = mode
501
+ await focusComposer()
502
+ }
503
+ function toggleDetails(id: string) {
504
+ expandedId.value = expandedId.value === id ? null : id
505
+ emit('select', expandedId.value)
506
+ }
507
+ function createTopic() {
508
+ if (!props.busy && topicTitle.value.trim())
509
+ emit('createTopic', topicTitle.value.trim())
510
+ }
511
+ function createTask() {
512
+ if (props.busy || !selectedTask.value) return
513
+ if (selectedTask.value.roomId) emit('open', selectedTask.value.roomId)
514
+ else emit('createTask', selectedTask.value.id)
515
+ }
516
+ </script>
517
+
518
+ <style src="./rooms-directory.css"></style>
@@ -0,0 +1,84 @@
1
+ export interface DirectoryRoom {
2
+ id: string
3
+ title: string
4
+ kind: 'topic' | 'task' | 'branch'
5
+ kindLabel?: string
6
+ closed: boolean
7
+ description: string
8
+ createdAt: string | null
9
+ closedAt: string | null
10
+ searchText?: string
11
+ }
12
+
13
+ export interface DirectoryTask {
14
+ id: string
15
+ title: string
16
+ description: string
17
+ status: string
18
+ roomId?: string
19
+ roomClosed?: boolean
20
+ }
21
+
22
+ export function roomDisplayTitle(title: string): string {
23
+ return title.replace(/^Focus:\s*/i, '').trim() || 'Untitled room'
24
+ }
25
+
26
+ export function filterRooms(
27
+ rooms: readonly DirectoryRoom[],
28
+ closed: boolean,
29
+ query: string,
30
+ ): DirectoryRoom[] {
31
+ const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
32
+ return rooms
33
+ .filter(
34
+ (room) =>
35
+ room.closed === closed &&
36
+ terms.every((term) =>
37
+ `${room.title} ${room.kind} ${room.kindLabel || ''} ${room.description} ${room.searchText || ''}`
38
+ .toLocaleLowerCase()
39
+ .includes(term),
40
+ ),
41
+ )
42
+ .sort((a, b) => {
43
+ const date = (room: DirectoryRoom) =>
44
+ Date.parse((closed ? room.closedAt : null) || room.createdAt || '') || 0
45
+ return (
46
+ date(b) - date(a) ||
47
+ a.title.localeCompare(b.title) ||
48
+ a.id.localeCompare(b.id)
49
+ )
50
+ })
51
+ }
52
+
53
+ export function roomDate(value: string | null): string {
54
+ if (!value || Number.isNaN(Date.parse(value))) return ''
55
+ return new Intl.DateTimeFormat(undefined, {
56
+ month: 'short',
57
+ day: 'numeric',
58
+ year: 'numeric',
59
+ }).format(new Date(value))
60
+ }
61
+
62
+ export function roomKindLabel(room: DirectoryRoom): string {
63
+ return (
64
+ room.kindLabel ||
65
+ (room.kind === 'branch'
66
+ ? 'Git branch'
67
+ : room.kind === 'task'
68
+ ? 'Task'
69
+ : 'Topic')
70
+ )
71
+ }
72
+
73
+ export function taskStatusLabel(status: string): string {
74
+ const labels: Record<string, string> = {
75
+ open: 'Open',
76
+ accepted: 'Ready to start',
77
+ assigned: 'Assigned',
78
+ in_progress: 'In progress',
79
+ in_review: 'Ready for review',
80
+ blocked: 'Blocked',
81
+ proposed: 'Proposed',
82
+ }
83
+ return labels[status] || status.replace(/_/g, ' ')
84
+ }