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.
- package/dist/mcp/local-state/local-chat.js +128 -74
- package/dist/mcp/server/runtime/room-api.js +4 -2
- package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -2
- package/dist/mcp/server/tools/messages/send-tool.js +2 -2
- package/dist/mcp/server/tools/messages/status-tool.js +2 -2
- package/dist/mcp/server/tools/tasks/api.js +14 -0
- package/package.json +2 -2
- package/shared/activation-routing.d.mts +1 -1
- package/shared/local-work-leases.d.mts +24 -0
- package/shared/local-work-leases.mjs +160 -0
- package/shared/rooms/RoomsDirectory.vue +518 -0
- package/shared/rooms/directory.ts +84 -0
- package/shared/rooms/rooms-directory.css +757 -0
- package/shared/task-markdown-editing.d.mts +9 -0
- package/shared/task-markdown-editing.mjs +53 -0
- package/shared/task-markdown.css +32 -0
|
@@ -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
|
+
}
|