Skip to content
Clone

feat(web): add agent labels, members, artifacts and read state

ZhenYi committed
parent 21a5db1 commit 8548a44 Browse files

7 files changed

+639 −3

File tree 7
Modified web/src/entities/agent/model/agent.ts View web/src/entities/agent/model/agent.ts
@@ -12,16 +12,24 @@
1212 * so every task id below accepts either form.
1313 */
1414import type {
15 AgentMemberKind,
1516 AgentPriority,
1617 AgentState,
18 AppResponseData_AgentArtifactDto,
1719 AppResponseData_AgentDto,
20 AppResponseData_AgentLabelDto,
21 AppResponseData_AgentMemberDto,
22 AppResponseData_AgentReadStateDto,
23 AppResponseData_AgentRepoRelDto,
1824 AppResponseData_AgentWatchDto,
1925 AppResponseData_CollectionDto,
2026 AppResponseData_TaskTokenDto,
27 UserDto,
2128 WatchMode,
2229} from '@/shared/api'
2330
2431export type {
32 AgentMemberKind,
2533 AgentPriority,
2634 AgentState,
2735 AppResponseData_AgentDto,
@@ -133,4 +141,55 @@
133141 description?: string
134142 icon?: string
135143 parent_dir_id?: string
144}
145
146/* ------------------------------------------------------------ collaboration */
147
148/** A collection-scoped label (`GET …/collections/{cid}/labels`). */
149export type AgentLabel = NonNullable<AppResponseData_AgentLabelDto['data']>
150
151/** One task membership row, `kind` telling the role apart. */
152export type AgentMember = NonNullable<AppResponseData_AgentMemberDto['data']>
153
154/** A sandbox artifact with its presigned download link (`GET …/artifacts`). */
155export type AgentArtifact = NonNullable<AppResponseData_AgentArtifactDto['data']>
156
157/** Repo a task may check out, optionally pinned to a ref / sub path. */
158export type AgentRepoRel = NonNullable<AppResponseData_AgentRepoRelDto['data']>
159
160/** Per-user read cursor of one task; `unread` is derived, never stored. */
161export type AgentReadState = NonNullable<AppResponseData_AgentReadStateDto['data']>
162
163/** Space member row used by the assignee picker. */
164export interface AgentUserRow {
165 user?: UserDto | null
166 alias_name?: string | null
167}
168
169export interface NewAgentLabelCmd {
170 name: string
171 color: string
172 description?: string
173 exclusive?: boolean
174 exclusive_order?: number
175}
176
177export type AgentLabelPatchCmd = {
178 name?: string
179 color?: string
180 description?: string | null
181 exclusive?: boolean
182 exclusive_order?: number
183 is_archived?: boolean
184}
185
186export interface NewAgentMemberCmd {
187 user_id: string
188 kind?: AgentMemberKind
189}
190
191export interface NewAgentRepoRelCmd {
192 repo_id: string
193 ref_name?: string
194 sub_path?: string
136195}
No newline at end of file
Modified web/src/entities/agent/model/api.ts View web/src/entities/agent/model/api.ts
@@ -14,15 +14,26 @@
1414import Apis from '@/shared/api'
1515import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
1616import type {
17 AgentArtifact,
1718 AgentCollection,
1819 AgentEvent,
20 AgentLabel,
21 AgentLabelPatchCmd,
22 AgentMember,
23 AgentMemberKind,
1924 AgentMessage,
2025 AgentPage,
2126 AgentPatchCmd,
2227 AgentQuery,
28 AgentReadState,
29 AgentRepoRel,
2330 AgentTask,
31 AgentUserRow,
2432 AgentWatch,
2533 NewAgentCmd,
34 NewAgentLabelCmd,
35 NewAgentMemberCmd,
36 NewAgentRepoRelCmd,
2637 NewCollectionCmd,
2738 TaskToken,
2839 WatchMode,
@@ -226,4 +237,164 @@
226237 Apis.sagent.get_task_token({
227238 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
228239 }),
240 )
241
242/* ----------------------------------------------------------------- labels */
243
244/** Collection-scoped labels; every label lives on the collection's `dir` node. */
245export const listLabels = ({ space, collection }: AgentRef, query: { q?: string; isArchived?: boolean } = {}) =>
246 pageOf<AgentLabel>(
247 Apis.sagent.list_labels({
248 pathParams: { space_name: space, collection_id: collection },
249 params: { q: nz(query.q), is_archived: query.isArchived, limit: 100, offset: 0 },
250 }),
251 )
252
253export const createLabel = ({ space, collection }: AgentRef, cmd: NewAgentLabelCmd) =>
254 oneOf<AgentLabel>(
255 Apis.sagent.create_label({
256 pathParams: { space_name: space, collection_id: collection },
257 data: {
258 name: cmd.name,
259 color: cmd.color,
260 description: cmd.description ?? null,
261 exclusive: cmd.exclusive ?? null,
262 exclusive_order: cmd.exclusive_order ?? null,
263 },
264 }),
265 )
266
267export const updateLabel = ({ space, collection }: AgentRef, labelId: string, cmd: AgentLabelPatchCmd) =>
268 oneOf<AgentLabel>(
269 Apis.sagent.update_label({
270 pathParams: { space_name: space, collection_id: collection, label_id: labelId },
271 data: cmd,
272 }),
273 )
274
275export const deleteLabel = ({ space, collection }: AgentRef, labelId: string) =>
276 unwrapApi<null>(
277 Apis.sagent.delete_label({
278 pathParams: { space_name: space, collection_id: collection, label_id: labelId },
279 }),
280 )
281
282/** Labels bound to one task; the response is a bare array, not a page. */
283export const listTaskLabels = ({ space, collection }: AgentRef, taskId: string) =>
284 oneOf<AgentLabel[]>(
285 Apis.sagent.list_task_labels({
286 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
287 }),
288 ).then(labels => labels ?? [])
289
290export const bindTaskLabel = ({ space, collection }: AgentRef, taskId: string, labelId: string) =>
291 unwrapApi<null>(
292 Apis.sagent.bind_label({
293 pathParams: { space_name: space, collection_id: collection, task_id: taskId, label_id: labelId },
294 }),
295 )
296
297export const unbindTaskLabel = ({ space, collection }: AgentRef, taskId: string, labelId: string) =>
298 unwrapApi<null>(
299 Apis.sagent.unbind_label({
300 pathParams: { space_name: space, collection_id: collection, task_id: taskId, label_id: labelId },
301 }),
302 )
303
304/* ---------------------------------------------------------------- members */
305
306export const listMembers = ({ space, collection }: AgentRef, taskId: string, kind?: AgentMemberKind) =>
307 pageOf<AgentMember>(
308 Apis.sagent.list_members({
309 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
310 params: { kind, limit: 100, offset: 0 },
311 }),
312 )
313
314export const addMember = ({ space, collection }: AgentRef, taskId: string, cmd: NewAgentMemberCmd) =>
315 oneOf<AgentMember>(
316 Apis.sagent.create_member({
317 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
318 data: { user_id: cmd.user_id, kind: cmd.kind ?? null },
319 }),
320 )
321
322export const removeMember = ({ space, collection }: AgentRef, taskId: string, userId: string) =>
323 unwrapApi<null>(
324 Apis.sagent.delete_member({
325 pathParams: { space_name: space, collection_id: collection, task_id: taskId, user_id: userId },
326 }),
327 )
328
329/** Space members for the assignee picker; the agent domain only needs identity. */
330export const listSpaceMembers = (space: string) =>
331 pageOf<AgentUserRow>(
332 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
333 )
334
335/* ------------------------------------------------------------------- repo */
336
337/** Repos a task may check out (`POST/GET …/tasks/{task_id}/repos`). */
338export const listRepoRelations = ({ space, collection }: AgentRef, taskId: string) =>
339 oneOf<AgentRepoRel[]>(
340 Apis.sagent.list_repo_relations({
341 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
342 }),
343 ).then(rows => rows ?? [])
344
345export const createRepoRelation = ({ space, collection }: AgentRef, taskId: string, cmd: NewAgentRepoRelCmd) =>
346 oneOf<AgentRepoRel>(
347 Apis.sagent.create_repo_relation({
348 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
349 data: { repo_id: cmd.repo_id, ref_name: cmd.ref_name ?? null, sub_path: cmd.sub_path ?? null },
350 }),
351 )
352
353export const deleteRepoRelation = ({ space, collection }: AgentRef, taskId: string, relId: string) =>
354 unwrapApi<null>(
355 Apis.sagent.delete_repo_relation({
356 pathParams: { space_name: space, collection_id: collection, task_id: taskId, rel_id: relId },
357 }),
358 )
359
360/* -------------------------------------------------------------- artifacts */
361
362/** Sandbox outputs, each carrying a freshly presigned download link. */
363export const listArtifacts = ({ space, collection }: AgentRef, taskId: string, query: { kind?: string } = {}) =>
364 pageOf<AgentArtifact>(
365 Apis.sagent.list_artifacts({
366 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
367 params: { kind: nz(query.kind), limit: 100, offset: 0 },
368 }),
369 )
370
371export const getArtifact = ({ space, collection }: AgentRef, taskId: string, artifactId: string) =>
372 oneOf<AgentArtifact>(
373 Apis.sagent.get_artifact({
374 pathParams: {
375 space_name: space,
376 collection_id: collection,
377 task_id: taskId,
378 artifact_id: artifactId,
379 },
380 }),
381 )
382
383/* ------------------------------------------------------------- read state */
384
385/** Read cursor of the signed-in user; `unread` is derived from the watermark. */
386export const getReadState = ({ space, collection }: AgentRef, taskId: string) =>
387 oneOf<AgentReadState>(
388 Apis.sagent.get_read_state({
389 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
390 }),
391 )
392
393/** Marks the task read; omitting the cursor means "up to the current watermark". */
394export const markTaskRead = ({ space, collection }: AgentRef, taskId: string, lastReadSeq?: number) =>
395 oneOf<AgentReadState>(
396 Apis.sagent.update_read_state({
397 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
398 data: { last_read_seq: lastReadSeq ?? null },
399 }),
229400 )
No newline at end of file
Modified web/src/entities/agent/model/meta.ts View web/src/entities/agent/model/meta.ts
@@ -6,6 +6,7 @@
66 */
77import { isMessageKey, t } from '@/shared/i18n'
88import type {
9 AgentMemberKind,
910 AgentPriority,
1011 AgentSort,
1112 AgentState,
@@ -117,6 +118,34 @@
117118 },
118119}
119120
121/** Roles a user can hold on one task (`AgentMemberKind` in `lib/models`). */
122export const AGENT_MEMBER_KINDS: readonly AgentMemberKind[] = ['assignee', 'mentioned', 'participant']
123
124export const MEMBER_KIND_LABELS: Readonly<Record<AgentMemberKind, string>> = {
125 get assignee() {
126 return t('agent.member.assignee')
127 },
128 get mentioned() {
129 return t('agent.member.mentioned')
130 },
131 get participant() {
132 return t('agent.member.participant')
133 },
134}
135
136/** Artifact size in the largest unit that keeps the number readable. */
137export function artifactSize(bytes: number): string {
138 if (!Number.isFinite(bytes) || bytes < 1024) return `${bytes} B`
139 const units = ['KB', 'MB', 'GB', 'TB']
140 let value = bytes / 1024
141 let unit = 0
142 while (value >= 1024 && unit < units.length - 1) {
143 value /= 1024
144 unit += 1
145 }
146 return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
147}
148
120149export const ROLE_LABELS: Readonly<Record<string, string>> = {
121150 get user() {
122151 return t('agent.role.user')
@@ -192,7 +221,7 @@
192221 if (typeof content === 'string') return content
193222 if (typeof content === 'object') {
194223 const row = content as Record<string, unknown>
195 for (const key of ['state', 'title', 'name', 'message', 'kind', 'reason']) {
224 for (const key of ['state', 'title', 'name', 'message', 'kind', 'reason', 'repo_name', 'label']) {
196225 const value = row[key]
197226 if (typeof value === 'string' && value) return value
198227 }
Added web/src/features/agent-console/ui/agent-collab.tsx View web/src/features/agent-console/ui/agent-collab.tsx
@@ -0,0 +1,279 @@
1/**
2 * Collaboration panels of the task detail: labels, members and artifacts.
3 *
4 * Each panel owns its own fetch so a failing endpoint only blanks that panel
5 * (the detail body and the transcript keep working). Labels are created on the
6 * collection — a label is bound to a task, but it lives on the collection's
7 * `dir` node — so the create dialog posts to the collection, never the task.
8 *
9 * Artifacts come from the sandbox, so the panel only lists and downloads them:
10 * the download link is a short-lived presigned URL minted per response, which is
11 * why it is opened directly instead of being routed through the SPA.
12 */
13import { useState } from 'react'
14import { Download, Plus, X } from 'lucide-react'
15import { Alert, Button, Chip, EmptyNote, FormModal, useSave, type FormField, type FormValues } from '@/shared/ui'
16import { useT } from '@/shared/i18n'
17import { agentKey, spaceMembersPageKey, useAsync } from '@/shared/lib'
18import {
19 addMember,
20 artifactSize,
21 bindTaskLabel,
22 createLabel,
23 listArtifacts,
24 listLabels,
25 listMembers,
26 listSpaceMembers,
27 listTaskLabels,
28 MEMBER_KIND_LABELS,
29 removeMember,
30 unbindTaskLabel,
31 type AgentRef,
32} from '@/entities/agent'
33
34export interface TaskCollabProps {
35 agentRef: AgentRef
36 taskId: string
37 onChanged: () => void
38}
39
40const sectionTitleCls =
41 'text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted'
42
43/* ------------------------------------------------------------------ labels */
44
45export function TaskLabels({ agentRef, taskId, onChanged }: TaskCollabProps) {
46 const { t } = useT()
47 const save = useSave()
48 const [reload, setReload] = useState(0)
49 const [creating, setCreating] = useState(false)
50
51 const catalog = useAsync(() => listLabels(agentRef), [agentRef.space, agentRef.collection, reload], {
52 cacheKey: agentKey(agentRef.space, agentRef.collection, 'labels'),
53 })
54 const bound = useAsync(() => listTaskLabels(agentRef, taskId), [agentRef.space, agentRef.collection, taskId, reload])
55
56 const boundLabels = bound.data ?? []
57 const boundIds = new Set(boundLabels.map(label => label.id))
58 const available = (catalog.data?.items ?? []).filter(label => !boundIds.has(label.id) && !label.is_archived)
59
60 const refresh = () => setReload(tick => tick + 1)
61
62 const bind = async (labelId: string) => {
63 if (!labelId) return
64 await save.run(() => bindTaskLabel(agentRef, taskId, labelId))
65 refresh()
66 onChanged()
67 }
68
69 const unbind = async (labelId: string) => {
70 await save.run(() => unbindTaskLabel(agentRef, taskId, labelId))
71 refresh()
72 onChanged()
73 }
74
75 async function create(values: FormValues) {
76 const name = String(values.name ?? '').trim()
77 if (!name) throw new Error(t('agent.labels.nameRequired'))
78 await createLabel(agentRef, {
79 name,
80 color: String(values.color ?? '').trim() || '#66717d',
81 description: String(values.description ?? '').trim() || undefined,
82 })
83 refresh()
84 }
85
86 const fields: FormField[] = [
87 { kind: 'text', key: 'name', label: t('agent.labels.name'), maxLength: 50 },
88 { kind: 'text', key: 'color', label: t('agent.labels.color'), initialValue: '#4f8ef7', maxLength: 7 },
89 { kind: 'textarea', key: 'description', label: t('agent.console.form.description'), optional: true, rows: 2 },
90 ]
91
92 return (
93 <section className="flex flex-col gap-2">
94 <h3 className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
95 {t('agent.labels.title')}
96 <button
97 type="button"
98 onClick={() => setCreating(true)}
99 className="inline-flex items-center gap-0.5 text-[11px] font-normal normal-case tracking-normal text-shell-ink-muted hover:text-shell-ink"
100 >
101 <Plus className="h-3 w-3" />
102 {t('agent.labels.new')}
103 </button>
104 </h3>
105 {save.error ? <Alert>{`${t('agent.console.actionFailed')}: ${save.error}`}</Alert> : null}
106 {boundLabels.length === 0 ? (
107 <EmptyNote>{t('agent.labels.empty')}</EmptyNote>
108 ) : (
109 <div className="flex flex-wrap items-center gap-1.5">
110 {boundLabels.map(label => (
111 <span
112 key={label.id}
113 className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
114 style={{ backgroundColor: label.color }}
115 >
116 {label.name}
117 <button
118 type="button"
119 title={t('agent.labels.unbind')}
120 aria-label={t('agent.labels.unbind')}
121 onClick={() => void unbind(label.id)}
122 >
123 <X className="h-3 w-3" />
124 </button>
125 </span>
126 ))}
127 </div>
128 )}
129 {available.length > 0 ? (
130 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
131 {t('agent.labels.add')}
132 <select
133 value=""
134 onChange={event => void bind(event.target.value)}
135 className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink"
136 >
137 <option value="">—</option>
138 {available.map(label => (
139 <option key={label.id} value={label.id}>
140 {label.name}
141 </option>
142 ))}
143 </select>
144 </label>
145 ) : null}
146 <FormModal
147 title={t('agent.labels.createTitle')}
148 open={creating}
149 fields={fields}
150 confirmLabel={t('common.action.create')}
151 onSubmit={create}
152 onClose={() => setCreating(false)}
153 />
154 </section>
155 )
156}
157
158/* ----------------------------------------------------------------- members */
159
160export function TaskMembers({ agentRef, taskId, onChanged }: TaskCollabProps) {
161 const { t } = useT()
162 const save = useSave()
163 const [reload, setReload] = useState(0)
164
165 const members = useAsync(() => listMembers(agentRef, taskId), [agentRef.space, agentRef.collection, taskId, reload])
166 const spaceMembers = useAsync(() => listSpaceMembers(agentRef.space), [agentRef.space], {
167 cacheKey: spaceMembersPageKey(agentRef.space),
168 })
169
170 const rows = members.data?.items ?? []
171 const taken = new Set(rows.map(row => row.user?.user_id).filter(Boolean) as string[])
172 const candidates = (spaceMembers.data?.items ?? []).filter(row => row.user?.user_id && !taken.has(row.user.user_id))
173
174 const refresh = () => setReload(tick => tick + 1)
175
176 const add = async (userId: string) => {
177 if (!userId) return
178 await save.run(() => addMember(agentRef, taskId, { user_id: userId, kind: 'assignee' }))
179 refresh()
180 onChanged()
181 }
182
183 const remove = async (userId: string) => {
184 await save.run(() => removeMember(agentRef, taskId, userId))
185 refresh()
186 onChanged()
187 }
188
189 return (
190 <section className="flex flex-col gap-2">
191 <h3 className={sectionTitleCls}>{t('agent.member.title')}</h3>
192 {save.error ? <Alert>{`${t('agent.console.actionFailed')}: ${save.error}`}</Alert> : null}
193 {rows.length === 0 ? (
194 <EmptyNote>{t('agent.member.empty')}</EmptyNote>
195 ) : (
196 <div className="flex flex-wrap items-center gap-1.5">
197 {rows.map(row => {
198 const userId = row.user?.user_id ?? ''
199 return (
200 <Chip key={row.id}>
201 <span>{row.user?.display_name ?? row.user?.username ?? userId}</span>
202 <span className="text-shell-ink-faint">{MEMBER_KIND_LABELS[row.kind]}</span>
203 {userId ? (
204 <button
205 type="button"
206 title={t('agent.member.remove')}
207 aria-label={t('agent.member.remove')}
208 onClick={() => void remove(userId)}
209 >
210 <X className="h-3 w-3" />
211 </button>
212 ) : null}
213 </Chip>
214 )
215 })}
216 </div>
217 )}
218 {candidates.length > 0 ? (
219 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
220 {t('agent.member.add')}
221 <select
222 value=""
223 onChange={event => void add(event.target.value)}
224 className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink"
225 >
226 <option value="">—</option>
227 {candidates.map(row => (
228 <option key={row.user?.user_id} value={row.user?.user_id}>
229 {row.alias_name ?? row.user?.display_name ?? row.user?.username}
230 </option>
231 ))}
232 </select>
233 </label>
234 ) : null}
235 </section>
236 )
237}
238
239/* --------------------------------------------------------------- artifacts */
240
241export function TaskArtifacts({ agentRef, taskId }: TaskCollabProps) {
242 const { t } = useT()
243 const artifacts = useAsync(() => listArtifacts(agentRef, taskId), [agentRef.space, agentRef.collection, taskId])
244 const rows = artifacts.data?.items ?? []
245
246 const download = (url: string | null | undefined) => {
247 if (!url) return
248 window.open(url, '_blank', 'noopener')
249 }
250
251 return (
252 <section className="flex flex-col gap-2">
253 <h3 className={sectionTitleCls}>{t('agent.artifact.title')}</h3>
254 {rows.length === 0 ? (
255 <EmptyNote>{t('agent.artifact.empty')}</EmptyNote>
256 ) : (
257 <ul className="flex flex-col gap-1.5">
258 {rows.map(artifact => (
259 <li key={artifact.id} className="flex items-center gap-2 text-[12.5px]">
260 <span className="min-w-0 flex-1 truncate">{artifact.name}</span>
261 <Chip>{artifact.kind}</Chip>
262 <span className="text-[11.5px] text-shell-ink-muted">{artifactSize(artifact.size)}</span>
263 <Button
264 variant="ghost"
265 className="h-6 px-2 text-[11.5px]"
266 disabled={!artifact.download_url}
267 title={artifact.download_url ? undefined : t('agent.artifact.missingLink')}
268 onClick={() => download(artifact.download_url)}
269 >
270 <Download className="h-3 w-3" />
271 {t('agent.artifact.download')}
272 </Button>
273 </li>
274 ))}
275 </ul>
276 )}
277 </section>
278 )
279}
No newline at end of file
Modified web/src/features/agent-console/ui/agent-detail.tsx View web/src/features/agent-console/ui/agent-detail.tsx
@@ -6,8 +6,8 @@
66 * instead of letting the user click into a 409. Everything else — messages and
77 * the activity feed — is a read-only mirror of the gateway session.
88 */
9import { useState } from 'react'
10import { ArrowLeft, Bot, CirclePlay, Pencil, Send, Square, Trash2, Undo2, User } from 'lucide-react'
9import { useEffect, useState } from 'react'
10import { ArrowLeft, Bot, CheckCheck, CirclePlay, Pencil, Send, Square, Trash2, Undo2, User } from 'lucide-react'
1111import { Alert, Button, ErrorPanel, Markdown } from '@/shared/ui'
1212import { useSave } from '@/shared/ui'
1313import { useT, type MessageKey } from '@/shared/i18n'
@@ -19,8 +19,10 @@
1919 closeTask,
2020 eventLabel,
2121 describeEventContent,
22 getReadState,
2223 getTaskToken,
2324 isActiveState,
25 markTaskRead,
2426 priorityOf,
2527 reopenTask,
2628 ROLE_LABELS,
@@ -30,10 +32,12 @@
3032 WATCH_LABELS,
3133 WATCH_MODES,
3234 type AgentMessage,
35 type AgentReadState,
3336 type AgentRef,
3437 type WatchMode,
3538} from '@/entities/agent'
3639import type { AgentConsoleData } from '../model/use-agent-console-data'
40import { TaskArtifacts, TaskLabels, TaskMembers } from './agent-collab'
3741
3842/** A transcript row, whichever side of the wire it came from. */
3943interface Turn {
@@ -103,6 +107,23 @@
103107 const [draft, setDraft] = useState('')
104108 const [sending, setSending] = useState(false)
105109 const [sendError, setSendError] = useState<string | null>(null)
110 // The read cursor is per user and lives server-side; the panel only mirrors
111 // it so an opening user can see how much happened since the last visit.
112 const [readState, setReadState] = useState<AgentReadState | null>(null)
113 const taskId = task?.id ?? null
114
115 useEffect(() => {
116 if (!taskId) return
117 let active = true
118 getReadState(agentRef, taskId)
119 .then(state => {
120 if (active) setReadState(state)
121 })
122 .catch(() => undefined)
123 return () => {
124 active = false
125 }
126 }, [agentRef, taskId])
106127 const transcript = mergeAgentMessages(ARCHIVE_TURNS(messages), LIVE_TURNS(live.messages))
107128 const streamAlarm = live.status === 'error' || live.status === 'expired' || live.status === 'reconnecting'
108129
@@ -155,6 +176,12 @@
155176 await save.run(() => setTaskWatch(agentRef, task.id, mode))
156177 }
157178
179 const markRead = async () => {
180 await save.run(async () => {
181 setReadState(await markTaskRead(agentRef, task.id))
182 })
183 }
184
158185 // `send` goes straight to the gateway session; the API host has no send route
159186 // on purpose, so quota and moderation hooks do not exist for follow-ups.
160187 const sendDraft = async () => {
@@ -208,6 +235,17 @@
208235 {` ${fmtTime(task.due_at)}`}
209236 </span>
210237 ) : null}
238 {readState && readState.unread > 0 ? (
239 <button
240 type="button"
241 onClick={() => void markRead()}
242 disabled={save.busy}
243 className="inline-flex items-center gap-1 rounded-full bg-shell-field px-2 py-0.5 text-[11px] font-semibold text-shell-ink-muted hover:text-shell-ink"
244 >
245 <CheckCheck className="h-3 w-3" />
246 {t('agent.read.unread', { count: readState.unread })}
247 </button>
248 ) : null}
211249 </div>
212250 </div>
213251 </header>
@@ -296,6 +334,10 @@
296334 {started ? <p className="text-[11.5px] text-shell-ink-muted">{t('agent.console.task.bodyFrozen')}</p> : null}
297335 </section>
298336
337 <TaskLabels agentRef={agentRef} taskId={task.id} onChanged={onChanged} />
338
339 <TaskMembers agentRef={agentRef} taskId={task.id} onChanged={onChanged} />
340
299341 <section className="flex flex-col gap-2">
300342 <h3 className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
301343 {t('agent.console.task.conversation')}
@@ -379,6 +421,8 @@
379421 </ol>
380422 )}
381423 </section>
424
425 <TaskArtifacts agentRef={agentRef} taskId={task.id} onChanged={onChanged} />
382426 </article>
383427 )
384428}
No newline at end of file
Modified web/src/shared/i18n/messages/en/agent.ts View web/src/shared/i18n/messages/en/agent.ts
@@ -104,10 +104,37 @@
104104 'agent.event.run_failed': 'run failed',
105105 'agent.event.artifact_added': 'produced an artifact',
106106 'agent.event.subscriber_lost': 'live subscription dropped',
107 'agent.event.assigned': 'assigned a member',
108 'agent.event.unassigned': 'removed a member',
109 'agent.event.label_added': 'added a label',
110 'agent.event.label_removed': 'removed a label',
111 'agent.event.repo_relation_added': 'linked a repository',
112 'agent.event.repo_relation_removed': 'unlinked a repository',
107113 'agent.event.closed': 'closed the task',
108114 'agent.event.reopened': 'reopened the task',
109115 'agent.event.unknown': 'Unknown activity',
110116 'agent.event.fallback': 'Activity: {type}',
117 'agent.labels.title': 'Labels',
118 'agent.labels.empty': 'No labels yet.',
119 'agent.labels.add': 'Add',
120 'agent.labels.new': 'New label',
121 'agent.labels.createTitle': 'New label',
122 'agent.labels.name': 'Name',
123 'agent.labels.color': 'Colour',
124 'agent.labels.nameRequired': 'Enter a label name',
125 'agent.labels.unbind': 'Remove label',
126 'agent.member.title': 'Members',
127 'agent.member.empty': 'No members yet.',
128 'agent.member.add': 'Add',
129 'agent.member.remove': 'Remove member',
130 'agent.member.assignee': 'Assignee',
131 'agent.member.mentioned': 'Mentioned',
132 'agent.member.participant': 'Participant',
133 'agent.artifact.title': 'Artifacts',
134 'agent.artifact.empty': 'No artifacts yet.',
135 'agent.artifact.download': 'Download',
136 'agent.artifact.missingLink': 'Download link unavailable',
137 'agent.read.unread': '{count} unread',
111138 'space.menu.createAgent': 'New agent collection',
112139 'space.agent.create.title': 'Create agent collection',
113140 'space.agent.nameField': 'Name',
Modified web/src/shared/i18n/messages/zh-CN/agent.ts View web/src/shared/i18n/messages/zh-CN/agent.ts
@@ -104,10 +104,37 @@
104104 'agent.event.run_failed': '执行失败',
105105 'agent.event.artifact_added': '产出了新文件',
106106 'agent.event.subscriber_lost': '实时订阅中断',
107 'agent.event.assigned': '指派了成员',
108 'agent.event.unassigned': '移除了成员',
109 'agent.event.label_added': '添加了标签',
110 'agent.event.label_removed': '移除了标签',
111 'agent.event.repo_relation_added': '关联了仓库',
112 'agent.event.repo_relation_removed': '取消关联仓库',
107113 'agent.event.closed': '关闭了任务',
108114 'agent.event.reopened': '重新打开了任务',
109115 'agent.event.unknown': '未知活动',
110116 'agent.event.fallback': '活动:{type}',
117 'agent.labels.title': '标签',
118 'agent.labels.empty': '还没有绑定标签。',
119 'agent.labels.add': '添加',
120 'agent.labels.new': '新建标签',
121 'agent.labels.createTitle': '新建标签',
122 'agent.labels.name': '标签名',
123 'agent.labels.color': '颜色',
124 'agent.labels.nameRequired': '请填写标签名',
125 'agent.labels.unbind': '移除标签',
126 'agent.member.title': '成员',
127 'agent.member.empty': '还没有成员。',
128 'agent.member.add': '添加',
129 'agent.member.remove': '移除成员',
130 'agent.member.assignee': '负责人',
131 'agent.member.mentioned': '被提及',
132 'agent.member.participant': '参与者',
133 'agent.artifact.title': '产物',
134 'agent.artifact.empty': '还没有产物。',
135 'agent.artifact.download': '下载',
136 'agent.artifact.missingLink': '下载链接不可用',
137 'agent.read.unread': '{count} 条未读',
111138 'space.menu.createAgent': '新建智能体集',
112139 'space.agent.create.title': '创建智能体集',
113140 'space.agent.nameField': '名称',