feat(web): add smart agent console
ZhenYi committed
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +2 −0 View web/src/app/routes/router.tsx
@@ -38,10 +38,12 @@
3838 { path: '@me/notify' },
3939 { path: '@me/chat/:roomId?' },
4040 { path: '@me/board/:boardId?' },
41 { path: '@me/agent/:collectionId?' },
4142 { path: '@me/repo/:repoName?' },
4243 { path: ':spaceName' },
4344 { path: ':spaceName/chat/:roomId?' },
4445 { path: ':spaceName/board/:boardId?' },
46 { path: ':spaceName/agent/:collectionId?' },
4547 { path: ':spaceName/repo/:repoName?' },
4648 ],
4749 },
Added web/src/entities/agent/model/agent.ts web/src/entities/agent/model/agent.ts +136 −0 View web/src/entities/agent/model/agent.ts
@@ -0,0 +1,136 @@
1/**
2 * Sandbox-agent (智能体) row shapes.
3 *
4 * Types are derived from the generated response envelopes wherever the backend
5 * exposes one, so a schema change in `bun run gen:api` shows up here as a type
6 * error instead of a runtime surprise. Paged payloads are typed as the generic
7 * `Page` schema by the generator, so their item rows are declared structurally
8 * (they mirror `AgentMessageDto` / `AgentEventDto` in `lib/sagent`), and the
9 * two enums the generator inlines into the DTOs are declared here.
10 *
11 * A 智能体任务 is addressable by uuid *or* collection-scoped number (`?task=12`),
12 * so every task id below accepts either form.
13 */
14import type {
15 AgentPriority,
16 AgentState,
17 AppResponseData_AgentDto,
18 AppResponseData_AgentWatchDto,
19 AppResponseData_CollectionDto,
20 AppResponseData_TaskTokenDto,
21 WatchMode,
22} from '@/shared/api'
23
24export type {
25 AgentPriority,
26 AgentState,
27 AppResponseData_AgentDto,
28 AppResponseData_TaskTokenDto,
29 WatchMode,
30}
31
32/** Who wrote a mirrored message (`AgentRole` in `lib/models`). */
33export type AgentRole = 'user' | 'agent' | 'system' | 'tool'
34
35/** Activity kinds (`AgentEventType` in `lib/models`). */
36export type AgentEventType =
37 | 'created'
38 | 'title_updated'
39 | 'body_updated'
40 | 'started'
41 | 'state_changed'
42 | 'message_added'
43 | 'run_event'
44 | 'run_failed'
45 | 'artifact_added'
46 | 'assigned'
47 | 'unassigned'
48 | 'label_added'
49 | 'label_removed'
50 | 'repo_relation_added'
51 | 'repo_relation_removed'
52 | 'subscriber_lost'
53 | 'closed'
54 | 'reopened'
55
56/** One agent collection, i.e. the `dir` node's payload (`GET /agents/{space}/collections`). */
57export type AgentCollection = NonNullable<AppResponseData_CollectionDto['data']>
58
59/** Full task payload (`GET …/tasks/{task_id}`). */
60export type AgentTask = NonNullable<AppResponseData_AgentDto['data']>
61
62/** A watch row for the signed-in user (`GET/PUT …/tasks/{task_id}/watch`). */
63export type AgentWatch = NonNullable<AppResponseData_AgentWatchDto['data']>
64
65/** Session credentials for the gateway's session surface (`GET …/token`). */
66export type TaskToken = NonNullable<AppResponseData_TaskTokenDto['data']>
67
68/** Row of `GET …/tasks/{task_id}/messages` — the local mirror of the session transcript. */
69export interface AgentMessage {
70 id: string
71 agent_id: string
72 seq: number
73 gateway_message_id?: string | null
74 author_id?: string | null
75 role: AgentRole
76 content: string
77 created_at: string
78}
79
80/** Row of `GET …/tasks/{task_id}/events`. */
81export interface AgentEvent {
82 id: string
83 agent_id?: string | null
84 collection_id?: string | null
85 user_id?: string | null
86 event_type: AgentEventType | string
87 run_id?: string | null
88 content?: unknown
89 created_at: string
90}
91
92/** The generic `Page` schema, narrowed to one row type. */
93export interface AgentPage<T> {
94 items: T[]
95 total: number
96 limit: number
97 offset: number
98}
99
100/** `all` is a UI-only choice: omitting the key is what "no state filter" means. */
101export type AgentStateFilter = AgentState | 'all'
102
103export type AgentSort = 'created' | 'updated' | 'priority' | 'number'
104
105/** Server-side filters of `GET …/tasks`. */
106export interface AgentQuery {
107 q?: string
108 state?: AgentStateFilter
109 priority?: AgentPriority | ''
110 sort?: AgentSort
111 limit?: number
112 offset?: number
113}
114
115export interface NewAgentCmd {
116 title: string
117 description?: string
118 body?: string
119 priority?: AgentPriority
120 due_at?: string | null
121}
122
123export type AgentPatchCmd = {
124 title?: string
125 description?: string | null
126 body?: string | null
127 priority?: AgentPriority
128 due_at?: string | null
129}
130
131export interface NewCollectionCmd {
132 name: string
133 description?: string
134 icon?: string
135 parent_dir_id?: string
136}
No newline at end of file
Added web/src/entities/agent/model/api.ts web/src/entities/agent/model/api.ts +229 −0 View web/src/entities/agent/model/api.ts
@@ -0,0 +1,229 @@
1/**
2 * Sandbox-agent transport: every call the 智能体 pages make, wrapped in the
3 * shared envelope contract so the UI only ever sees unwrapped rows or an
4 * `ApiFailure`.
5 *
6 * The generated client types paged endpoints against the generic `Page` schema
7 * (one shape for every list), so the row type is asserted here — the single
8 * place in the app allowed to do that, which keeps the casts out of the
9 * components and makes a schema drift show up as one localized error.
10 *
11 * Tasks are addressable by uuid *or* collection-scoped number (`?task=12`), so
12 * every `taskId` parameter below accepts either form.
13 */
14import Apis from '@/shared/api'
15import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
16import type {
17 AgentCollection,
18 AgentEvent,
19 AgentMessage,
20 AgentPage,
21 AgentPatchCmd,
22 AgentQuery,
23 AgentTask,
24 AgentWatch,
25 NewAgentCmd,
26 NewCollectionCmd,
27 TaskToken,
28 WatchMode,
29} from './agent'
30import { SORT_PARAMS } from './meta'
31
32/**
33 * Empty strings are dropped, not sent: Alova omits `undefined` query values but
34 * forwards `''`, and the backend's `Option<Uuid>` / `Option<AgentState>`
35 * deserialization rejects an empty `dir_id=` / `state=` with a 400.
36 */
37const nz = <T,>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
38
39/** Collection id path params repeated by almost every agent endpoint. */
40export interface AgentRef {
41 space: string
42 collection: string
43}
44
45const pageOf = <T,>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<AgentPage<T>> =>
46 unwrapApi<AgentPage<T> | null>(method as PromiseLike<ApiEnvelope<AgentPage<T> | null>>).then(p => ({
47 items: p?.items ?? [],
48 total: p?.total ?? 0,
49 limit: p?.limit ?? 0,
50 offset: p?.offset ?? 0,
51 }))
52
53const oneOf = <T,>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
54 unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
55
56/* ------------------------------------------------------------- collection */
57
58/** Every collection of one space, page by page (`GET /agents/{space}/collections`). */
59export const listCollections = (space: string, query: { q?: string; dirId?: string; limit?: number; offset?: number } = {}) =>
60 pageOf<AgentCollection>(
61 Apis.sagent.list_collections({
62 pathParams: { space_name: space },
63 params: { q: nz(query.q), dir_id: nz(query.dirId), limit: query.limit, offset: query.offset ?? 0 },
64 }),
65 )
66
67export const getCollection = (space: string, collectionId: string) =>
68 unwrapApi<AgentCollection>(
69 Apis.sagent.get_collection({ pathParams: { space_name: space, collection_id: collectionId } }),
70 )
71
72export const createCollection = (space: string, cmd: NewCollectionCmd) =>
73 oneOf<AgentCollection>(
74 Apis.sagent.create_collection({
75 pathParams: { space_name: space },
76 data: {
77 name: cmd.name,
78 parent_dir_id: cmd.parent_dir_id ?? null,
79 icon: cmd.icon ?? null,
80 description: cmd.description ?? null,
81 },
82 }),
83 )
84
85export const updateCollection = (
86 space: string,
87 collectionId: string,
88 cmd: { name?: string; icon?: string | null; description?: string | null },
89) =>
90 oneOf<AgentCollection>(
91 Apis.sagent.update_collection({
92 pathParams: { space_name: space, collection_id: collectionId },
93 data: cmd,
94 }),
95 )
96
97export const deleteCollection = (space: string, collectionId: string) =>
98 unwrapApi<null>(
99 Apis.sagent.delete_collection({ pathParams: { space_name: space, collection_id: collectionId } }),
100 )
101
102/* ------------------------------------------------------------------- task */
103
104/** One page of tasks with the console's filters applied server-side. */
105export const listTasks = ({ space, collection }: AgentRef, query: AgentQuery = {}) =>
106 pageOf<AgentTask>(
107 Apis.sagent.list_tasks({
108 pathParams: { space_name: space, collection_id: collection },
109 params: {
110 // `all` is a UI-only choice: the backend filters one concrete state, and
111 // omitting the key at all is what "no state filter" means.
112 state: query.state && query.state !== 'all' ? query.state : undefined,
113 q: nz(query.q),
114 priority: nz(query.priority),
115 sort: query.sort ? SORT_PARAMS[query.sort] : undefined,
116 limit: query.limit,
117 offset: query.offset ?? 0,
118 },
119 }),
120 )
121
122export const getTask = ({ space, collection }: AgentRef, taskId: string) =>
123 unwrapApi<AgentTask>(
124 Apis.sagent.get_task({
125 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
126 }),
127 )
128
129export const createTask = ({ space, collection }: AgentRef, cmd: NewAgentCmd) =>
130 oneOf<AgentTask>(
131 Apis.sagent.create_task({
132 pathParams: { space_name: space, collection_id: collection },
133 data: {
134 title: cmd.title,
135 description: cmd.description ?? null,
136 body: cmd.body ?? '',
137 priority: cmd.priority ?? 'none',
138 due_at: cmd.due_at ?? null,
139 },
140 }),
141 )
142
143export const updateTask = ({ space, collection }: AgentRef, taskId: string, cmd: AgentPatchCmd) =>
144 oneOf<AgentTask>(
145 Apis.sagent.update_task({
146 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
147 data: cmd,
148 }),
149 )
150
151export const deleteTask = ({ space, collection }: AgentRef, taskId: string) =>
152 unwrapApi<null>(
153 Apis.sagent.delete_task({
154 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
155 }),
156 )
157
158/**
159 * Hands the task brief to the sandbox agent: creates (or reuses) the gateway
160 * session, sends the brief as the first message and flips the task to
161 * `pending`. 409 means the task already ran or is closed.
162 */
163export const startTask = ({ space, collection }: AgentRef, taskId: string) =>
164 oneOf<AgentTask>(
165 Apis.sagent.start_task({
166 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
167 }),
168 )
169
170export const cancelTask = ({ space, collection }: AgentRef, taskId: string) =>
171 oneOf<AgentTask>(
172 Apis.sagent.cancel_task({
173 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
174 }),
175 )
176
177export const closeTask = ({ space, collection }: AgentRef, taskId: string) =>
178 oneOf<AgentTask>(
179 Apis.sagent.close_task({
180 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
181 }),
182 )
183
184export const reopenTask = ({ space, collection }: AgentRef, taskId: string) =>
185 oneOf<AgentTask>(
186 Apis.sagent.reopen_task({
187 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
188 }),
189 )
190
191/* --------------------------------------------------------- transcript */
192
193/** The mirrored transcript, oldest first; `afterSeq` resumes from a watermark. */
194export const listMessages = ({ space, collection }: AgentRef, taskId: string, afterSeq?: number, limit = 100) =>
195 pageOf<AgentMessage>(
196 Apis.sagent.list_messages({
197 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
198 params: { after_seq: afterSeq, limit },
199 }),
200 )
201
202export const listEvents = ({ space, collection }: AgentRef, taskId: string, limit = 50) =>
203 pageOf<AgentEvent>(
204 Apis.sagent.list_events({
205 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
206 params: { limit },
207 }),
208 )
209
210/** Watch state is per user; the backend upserts, so this is idempotent. */
211export const setTaskWatch = ({ space, collection }: AgentRef, taskId: string, mode: WatchMode) =>
212 unwrapApi<AgentWatch>(
213 Apis.sagent.upsert_watch({
214 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
215 data: { mode },
216 }),
217 ).catch(() => null)
218
219/**
220 * Session credentials for the gateway's session surface: the browser talks to
221 * `base_url` directly with `token` (see `shared/sagent`). Issuing requires the
222 * task to have been started at least once.
223 */
224export const getTaskToken = ({ space, collection }: AgentRef, taskId: string) =>
225 unwrapApi<TaskToken>(
226 Apis.sagent.get_task_token({
227 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
228 }),
229 )
No newline at end of file
Added web/src/entities/agent/model/meta.ts web/src/entities/agent/model/meta.ts +201 −0 View web/src/entities/agent/model/meta.ts
@@ -0,0 +1,201 @@
1/**
2 * Display metadata for agent rows: the labels, colours and ordering the console
3 * shows for values the API only names. Kept next to the domain (not in a
4 * component) because the cards, the filter chips, the list table and the detail
5 * panel all have to agree on them.
6 */
7import { isMessageKey, t } from '@/shared/i18n'
8import type {
9 AgentPriority,
10 AgentSort,
11 AgentState,
12 AgentStateFilter,
13 WatchMode,
14} from './agent'
15
16/** Backend enum order: an unfinished run reads left→right, then the outcomes. */
17export const AGENT_STATES: readonly AgentState[] = [
18 'draft',
19 'pending',
20 'planning',
21 'executing',
22 'waiting',
23 'succeeded',
24 'failed',
25 'canceled',
26 'closed',
27] as const
28
29/** States the archive subscriber still considers live (`ACTIVE_STATES` in `lib/sagent`). */
30export const AGENT_ACTIVE_STATES: readonly AgentState[] = ['pending', 'planning', 'executing', 'waiting'] as const
31
32export const AGENT_STATE_META: Readonly<Record<AgentState, { label: string; color: string }>> = {
33 draft: { get label() { return t('agent.state.draft') }, color: '#66717d' },
34 pending: { get label() { return t('agent.state.pending') }, color: '#8fb8ff' },
35 planning: { get label() { return t('agent.state.planning') }, color: '#8e4ec6' },
36 executing: { get label() { return t('agent.state.executing') }, color: '#00a2c7' },
37 waiting: { get label() { return t('agent.state.waiting') }, color: '#ffc53d' },
38 succeeded: { get label() { return t('agent.state.succeeded') }, color: '#3ba55d' },
39 failed: { get label() { return t('agent.state.failed') }, color: '#e5484d' },
40 canceled: { get label() { return t('agent.state.canceled') }, color: '#66717d' },
41 closed: { get label() { return t('agent.state.closed') }, color: '#66717d' },
42}
43
44export function stateOf(value: string | null | undefined): { label: string; color: string } {
45 return AGENT_STATE_META[value as AgentState] ?? { get label() { return t('agent.event.unknown') }, color: '#66717d' }
46}
47
48export function isActiveState(value: string | null | undefined): boolean {
49 return AGENT_ACTIVE_STATES.includes(value as AgentState)
50}
51
52export const STATE_FILTERS: ReadonlyArray<{ id: AgentStateFilter; label: string }> = [
53 ...AGENT_STATES.map(state => ({
54 id: state as AgentStateFilter,
55 get label() {
56 return AGENT_STATE_META[state].label
57 },
58 })),
59 { id: 'all', get label() { return t('agent.state.all') } },
60]
61
62export interface PriorityMeta {
63 id: AgentPriority
64 label: string
65 /** Badge background. */
66 color: string
67 /** Badge text colour that stays readable on `color`. */
68 text: string
69 /** High → low, used for client-side ordering. */
70 rank: number
71}
72
73/** Highest priority first — matches `?sort=priority` (backend sorts desc). */
74export const PRIORITIES: readonly PriorityMeta[] = [
75 { id: 'urgent', get label() { return t('label.priority.urgent') }, color: '#e5484d', text: '#ffffff', rank: 4 },
76 { id: 'high', get label() { return t('label.priority.high') }, color: '#f76808', text: '#ffffff', rank: 3 },
77 { id: 'medium', get label() { return t('label.priority.medium') }, color: '#ffc53d', text: '#3d2a00', rank: 2 },
78 { id: 'low', get label() { return t('label.priority.low') }, color: '#8fb8ff', text: '#12315e', rank: 1 },
79 { id: 'none', get label() { return t('label.priority.none') }, color: '#d6d9e0', text: '#3f434b', rank: 0 },
80] as const
81
82export const PRIORITY_META: Record<AgentPriority, PriorityMeta> = PRIORITIES.reduce(
83 (acc, meta) => ({ ...acc, [meta.id]: meta }),
84 {} as Record<AgentPriority, PriorityMeta>,
85)
86
87export function priorityOf(value: string | null | undefined): PriorityMeta {
88 return PRIORITY_META[value as AgentPriority] ?? PRIORITY_META.none
89}
90
91export const SORTS: ReadonlyArray<{ id: AgentSort; label: string }> = [
92 { id: 'created', get label() { return t('agent.sort.created') } },
93 { id: 'updated', get label() { return t('agent.sort.updated') } },
94 { id: 'priority', get label() { return t('agent.sort.priority') } },
95 { id: 'number', get label() { return t('agent.sort.number') } },
96]
97
98export const WATCH_MODES: readonly WatchMode[] = ['watching', 'unwatched', 'ignore']
99
100/** Sort values the backend rejects are dropped so the query stays valid. */
101export const SORT_PARAMS: Record<AgentSort, string> = {
102 created: 'created',
103 updated: 'updated',
104 priority: 'priority',
105 number: 'number',
106}
107
108export const WATCH_LABELS: Readonly<Record<WatchMode, string>> = {
109 get watching() {
110 return t('agent.watch.watching')
111 },
112 get unwatched() {
113 return t('agent.watch.unwatched')
114 },
115 get ignore() {
116 return t('agent.watch.ignore')
117 },
118}
119
120export const ROLE_LABELS: Readonly<Record<string, string>> = {
121 get user() {
122 return t('agent.role.user')
123 },
124 get agent() {
125 return t('agent.role.agent')
126 },
127 get system() {
128 return t('agent.role.system')
129 },
130 get tool() {
131 return t('agent.role.tool')
132 },
133}
134
135/**
136 * Rust's `time` crate renders offsets as `+00:00:00` (seconds in the offset),
137 * which `Date` cannot parse. Same fold as `shared/lib/format`.
138 */
139export function parseAgentDate(value: string | null | undefined): Date | null {
140 if (!value) return null
141 const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
142 return Number.isNaN(date.getTime()) ? null : date
143}
144
145/** `MM-DD HH:mm` for the activity feed and the mirrored transcript. */
146export function shortTime(value: string | null | undefined): string {
147 const date = parseAgentDate(value)
148 if (!date) return ''
149 const pad = (n: number) => String(n).padStart(2, '0')
150 return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
151}
152
153/** `YYYY-MM-DD` for `<input type="date">`, from the backend's own timestamp. */
154export function dateInputValue(value: string | null | undefined): string {
155 const date = parseAgentDate(value)
156 if (!date) return ''
157 const pad = (n: number) => String(n).padStart(2, '0')
158 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
159}
160
161/** Send a date-only pick as UTC noon so it never shifts a day across timezones. */
162export function dateApiValue(input: string): string | null {
163 if (!input) return null
164 const [year, month, day] = input.split('-').map(Number)
165 if (!year || !month || !day) return null
166 return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).toISOString()
167}
168
169/**
170 * Activity line labels, keyed by the backend's `AgentEventType` snake_case
171 * values. Resolved lazily so the table follows the active locale; unknown types
172 * fall back to the raw value, so a new server-side event still renders.
173 */
174export const EVENT_LABELS: Readonly<Record<string, string>> = new Proxy(
175 {},
176 {
177 get: (_target, key: string) => {
178 const entry = `agent.event.${key}`
179 return isMessageKey(entry) ? t(entry) : undefined
180 },
181 },
182)
183
184export function eventLabel(type: string | null | undefined): string {
185 if (!type) return t('agent.event.unknown')
186 return EVENT_LABELS[type] ?? t('agent.event.fallback', { type })
187}
188
189/** One-line summary of an event's `content` blob, for the activity feed. */
190export function describeEventContent(content: unknown): string {
191 if (content === null || content === undefined) return ''
192 if (typeof content === 'string') return content
193 if (typeof content === 'object') {
194 const row = content as Record<string, unknown>
195 for (const key of ['state', 'title', 'name', 'message', 'kind', 'reason']) {
196 const value = row[key]
197 if (typeof value === 'string' && value) return value
198 }
199 }
200 return ''
201}
No newline at end of file
Added web/src/entities/agent/index.ts web/src/entities/agent/index.ts +8 −0 View web/src/entities/agent/index.ts
@@ -0,0 +1,8 @@
1/**
2 * Public API of the entities/agent slice — sandbox-agent (智能体) domain data.
3 *
4 * Consumers import from '@/entities/agent', never from deeper files.
5 */
6export * from './model/agent'
7export * from './model/meta'
8export * from './model/api'
No newline at end of file
Added web/src/features/agent-console/model/prefetch.ts web/src/features/agent-console/model/prefetch.ts +26 −0 View web/src/features/agent-console/model/prefetch.ts
@@ -0,0 +1,26 @@
1import { agentKey, prefetch } from '@/shared/lib'
2import { getCollection, listTasks, type AgentQuery } from '@/entities/agent'
3import { AGENT_PAGE_SIZE } from './use-agent-console-data'
4
5/**
6 * The query a console mounts with (agent-location defaults: state `all`, sort
7 * `created`, page 1). Field order matters — `useAgentConsoleData` derives its
8 * cache key from `JSON.stringify(query)`, so this literal must produce the
9 * exact same string as the console view's `buildAgentQuery`.
10 */
11const DEFAULT_QUERY: AgentQuery = {
12 state: 'all',
13 q: undefined,
14 priority: undefined,
15 sort: 'created',
16 limit: AGENT_PAGE_SIZE,
17 offset: 0,
18}
19
20/** Hover prefetch from the sidebar row: the collection and its first page. */
21export function prefetchAgentConsole(space: string, collection: string): void {
22 if (!space || !collection) return
23 const ref = { space, collection }
24 void prefetch(agentKey(space, collection, 'collection'), () => getCollection(space, collection))
25 void prefetch(agentKey(space, collection, `tasks:${JSON.stringify(DEFAULT_QUERY)}`), () => listTasks(ref, DEFAULT_QUERY))
26}
No newline at end of file
Added web/src/features/agent-console/model/query.ts web/src/features/agent-console/model/query.ts +52 −0 View web/src/features/agent-console/model/query.ts
@@ -0,0 +1,52 @@
1/**
2 * The console's view state, split from the URL encoding in
3 * `pages/workspace/model/agent-location.ts`: pages own the query string, the
4 * feature owns what the query string means so the view and the hover prefetch
5 * build byte-identical requests (the cache key is `JSON.stringify(query)`).
6 */
7import type { AgentPriority, AgentQuery, AgentSort, AgentStateFilter } from '@/entities/agent'
8import { AGENT_PAGE_SIZE } from './use-agent-console-data'
9
10/** Two layouts, same rows: `cards` for reading, `list` for scanning. */
11export type AgentLayout = 'cards' | 'list'
12
13export interface AgentConsoleView {
14 layout: AgentLayout
15 /** `all` is the UI's "no state filter" choice and never reaches the wire. */
16 state: AgentStateFilter
17 q: string
18 priority: AgentPriority | ''
19 sort: AgentSort
20 page: number
21 /** Selected task: uuid or collection-scoped number, empty when none is open. */
22 task: string
23}
24
25export const AGENT_VIEW_DEFAULTS: AgentConsoleView = {
26 layout: 'cards',
27 state: 'all',
28 q: '',
29 priority: '',
30 sort: 'created',
31 page: 1,
32 task: '',
33}
34
35export function normalizeAgentLayout(value: string | null | undefined): AgentLayout {
36 return value === 'list' ? 'list' : 'cards'
37}
38
39/**
40 * Field order is part of the contract: the cache key is the JSON of this
41 * object, so reordering the literal invalidates every stored page.
42 */
43export function buildAgentQuery(view: AgentConsoleView): AgentQuery {
44 return {
45 state: view.state,
46 q: view.q || undefined,
47 priority: view.priority || undefined,
48 sort: view.sort,
49 limit: AGENT_PAGE_SIZE,
50 offset: (view.page - 1) * AGENT_PAGE_SIZE,
51 }
52}
No newline at end of file
Added web/src/features/agent-console/model/use-agent-console-data.ts web/src/features/agent-console/model/use-agent-console-data.ts +100 −0 View web/src/features/agent-console/model/use-agent-console-data.ts
@@ -0,0 +1,100 @@
1/**
2 * Read side of the 智能体 console: one call per panel (collection, task page,
3 * one task, its mirrored transcript and its activity feed), each cached under
4 * an `agent:` key so switching collections or reopening the console paints from
5 * the local tier first.
6 *
7 * Nothing here writes: mutations live in `agent-view`, which owns the reload
8 * key that every fetch below is keyed on.
9 */
10import {
11 agentKey,
12 useAsync,
13 ApiFailure,
14 type AsyncState,
15} from '@/shared/lib'
16import {
17 getCollection,
18 getTask,
19 listEvents,
20 listMessages,
21 listTasks,
22 type AgentCollection,
23 type AgentEvent,
24 type AgentMessage,
25 type AgentQuery,
26 type AgentRef,
27 type AgentTask,
28} from '@/entities/agent'
29
30export const AGENT_PAGE_SIZE = 30
31export const AGENT_TASK_PAGE_SIZE = 30
32
33export interface AgentConsoleData {
34 collection?: AgentCollection
35 collectionFailure: ApiFailure | null
36 collectionLoading: boolean
37 tasks: AgentTask[]
38 total: number
39 tasksFailure: ApiFailure | null
40 tasksLoading: boolean
41 task?: AgentTask
42 taskFailure: ApiFailure | null
43 taskLoading: boolean
44 messages: AgentMessage[]
45 events: AgentEvent[]
46}
47
48const failureOf = (state: AsyncState<unknown>): ApiFailure | null =>
49 state.error instanceof ApiFailure ? state.error : null
50
51export function useAgentConsoleData(
52 ref: AgentRef,
53 query: AgentQuery,
54 taskId: string | null,
55 reloadKey: number,
56): AgentConsoleData {
57 const queryKey = JSON.stringify(query)
58 const { space, collection } = ref
59
60 const collectionState = useAsync(() => getCollection(space, collection), [space, collection, reloadKey], {
61 cacheKey: agentKey(space, collection, 'collection'),
62 })
63
64 const tasksState = useAsync(() => listTasks(ref, query), [space, collection, queryKey, reloadKey], {
65 cacheKey: agentKey(space, collection, `tasks:${queryKey}`),
66 })
67
68 const taskState = useAsync(
69 () => (taskId ? getTask(ref, taskId) : Promise.resolve(undefined)),
70 [space, collection, taskId, reloadKey],
71 { cacheKey: taskId ? agentKey(space, collection, `task:${taskId}`) : null },
72 )
73
74 const messagesState = useAsync(
75 () => (taskId ? listMessages(ref, taskId, undefined, 200) : Promise.resolve(null)),
76 [space, collection, taskId, reloadKey],
77 { cacheKey: taskId ? agentKey(space, collection, `messages:${taskId}`) : null },
78 )
79
80 const eventsState = useAsync(
81 () => (taskId ? listEvents(ref, taskId, 50) : Promise.resolve(null)),
82 [space, collection, taskId, reloadKey],
83 { cacheKey: taskId ? agentKey(space, collection, `events:${taskId}`) : null },
84 )
85
86 return {
87 collection: collectionState.data,
88 collectionFailure: failureOf(collectionState),
89 collectionLoading: collectionState.loading,
90 tasks: tasksState.data?.items ?? [],
91 total: tasksState.data?.total ?? 0,
92 tasksFailure: failureOf(tasksState),
93 tasksLoading: tasksState.loading,
94 task: taskState.data,
95 taskFailure: failureOf(taskState),
96 taskLoading: taskState.loading,
97 messages: messagesState.data?.items ?? [],
98 events: eventsState.data?.items ?? [],
99 }
100}
No newline at end of file
Added web/src/features/agent-console/ui/agent-console.tsx web/src/features/agent-console/ui/agent-console.tsx +174 −0 View web/src/features/agent-console/ui/agent-console.tsx
@@ -0,0 +1,174 @@
1/**
2 * The console body: the same rows as cards or as a table.
3 *
4 * The API has no board concept — a task is a state plus a priority — so there
5 * are no columns and no client-side filtering: every control in the toolbar is
6 * a real query parameter. Loading / empty / error / out-of-range paging are all
7 * rendered from the request state, never from placeholder data.
8 */
9import { ChevronLeft, ChevronRight, Inbox } from 'lucide-react'
10import { Button, ErrorPanel } from '@/shared/ui'
11import { useT } from '@/shared/i18n'
12import type { AgentTask } from '@/entities/agent'
13import type { AgentConsoleData } from '../model/use-agent-console-data'
14import { AGENT_PAGE_SIZE } from '../model/use-agent-console-data'
15import type { AgentConsoleView } from '../model/query'
16import { ConsoleEmpty, PriorityBadge, StateBadge, TaskCard, TaskMeta } from './parts'
17
18export interface AgentConsoleProps {
19 data: AgentConsoleData
20 view: AgentConsoleView
21 onFilter: (patch: Partial<AgentConsoleView>) => void
22 onOpenTask: (task: AgentTask) => void
23 onCreate: () => void
24 onRetry: () => void
25}
26
27export function AgentConsole({ data, view, onFilter, onOpenTask, onCreate, onRetry }: AgentConsoleProps) {
28 const { t } = useT()
29 const { tasks, total, tasksFailure, tasksLoading } = data
30 const limit = AGENT_PAGE_SIZE
31 const pages = Math.max(1, Math.ceil(total / limit))
32 const offset = (view.page - 1) * limit
33
34 if (tasksFailure && tasks.length === 0) {
35 return (
36 <div className="w-full rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
37 <ErrorPanel
38 status={tasksFailure.code}
39 what={t('agent.console.errorList')}
40 note={tasksFailure.message}
41 onRetry={onRetry}
42 />
43 </div>
44 )
45 }
46
47 if (tasksLoading && tasks.length === 0) {
48 return (
49 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
50 {Array.from({ length: 6 }, (_, i) => (
51 <div key={i} className="space-y-2 rounded-lg border-2 border-shell-hairline bg-shell-surface p-3">
52 <div className="h-4 w-2/3 rounded-full bg-shell-field" />
53 <div className="h-3 w-full rounded-full bg-shell-field" />
54 <div className="h-5 w-20 rounded-full bg-shell-field" />
55 </div>
56 ))}
57 </div>
58 )
59 }
60
61 // Paging past the end (a stale `?page=`, or tasks deleted meanwhile) would
62 // otherwise read as "no results" — say what it is and offer the way back.
63 if (tasks.length === 0 && offset > 0) {
64 return (
65 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-center">
66 <p className="text-[13px]">
67 {t('agent.console.pageOutOfRange', { page: view.page, total, limit })}
68 </p>
69 <Button variant="secondary" onClick={() => onFilter({ page: 1 })}>
70 {t('agent.console.backToFirst')}
71 </Button>
72 </div>
73 )
74 }
75
76 if (tasks.length === 0) {
77 const filtered = view.state !== 'all' || view.q !== '' || view.priority !== ''
78 return filtered ? (
79 <ConsoleEmpty icon={<Inbox className="h-6 w-6 text-shell-ink-muted" />} title={t('agent.console.emptyFiltered')} />
80 ) : (
81 <ConsoleEmpty
82 icon={<Inbox className="h-6 w-6 text-shell-ink-muted" />}
83 title={t('agent.console.empty')}
84 note={t('agent.console.emptyNote')}
85 />
86 )
87 }
88
89 return (
90 <div className="flex flex-col gap-3">
91 {view.layout === 'cards' ? (
92 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
93 {tasks.map(task => (
94 <TaskCard
95 key={task.id}
96 task={task}
97 active={view.task === String(task.number) || view.task === task.id}
98 onOpen={() => onOpenTask(task)}
99 />
100 ))}
101 </div>
102 ) : (
103 <div className="overflow-hidden rounded-lg border-2 border-shell-hairline bg-shell-surface">
104 <table className="w-full border-collapse text-left">
105 <thead>
106 <tr className="border-b border-shell-hairline text-[11.5px] uppercase tracking-[0.4px] text-shell-ink-muted">
107 <th className="px-3 py-2 font-medium">{t('agent.console.task.number')}</th>
108 <th className="px-3 py-2 font-medium">{t('agent.console.task.title')}</th>
109 <th className="px-3 py-2 font-medium">{t('agent.console.task.state')}</th>
110 <th className="px-3 py-2 font-medium">{t('agent.console.task.priority')}</th>
111 <th className="px-3 py-2 font-medium">{t('agent.console.task.createdAt')}</th>
112 </tr>
113 </thead>
114 <tbody>
115 {tasks.map(task => (
116 <tr key={task.id} className="border-b border-shell-hairline last:border-b-0 hover:bg-shell-field">
117 <td className="px-3 py-2 font-mono text-[12px] text-shell-ink-muted">{`#${task.number}`}</td>
118 <td className="px-3 py-2">
119 <button
120 type="button"
121 onClick={() => onOpenTask(task)}
122 className="max-w-[420px] truncate text-left text-[13px] font-medium hover:underline"
123 >
124 {task.title}
125 </button>
126 </td>
127 <td className="px-3 py-2">
128 <StateBadge state={task.state} />
129 </td>
130 <td className="px-3 py-2">
131 <PriorityBadge priority={task.priority} compact />
132 </td>
133 <td className="px-3 py-2 text-[12px] text-shell-ink-muted">
134 <TaskMeta task={task} />
135 </td>
136 </tr>
137 ))}
138 </tbody>
139 </table>
140 </div>
141 )}
142
143 <div className="flex items-center justify-between gap-2 px-1 text-[12px] text-shell-ink-muted">
144 <span>{t('agent.console.pager', { page: view.page, pages, total })}</span>
145 <span className="flex items-center gap-2">
146 <Button
147 variant="secondary"
148 className="h-7 px-2 text-[12px]"
149 disabled={view.page <= 1}
150 onClick={() => onFilter({ page: view.page - 1 })}
151 >
152 <ChevronLeft className="h-3.5 w-3.5" />
153 {t('agent.console.prev')}
154 </Button>
155 <Button
156 variant="secondary"
157 className="h-7 px-2 text-[12px]"
158 disabled={view.page >= pages}
159 onClick={() => onFilter({ page: view.page + 1 })}
160 >
161 {t('agent.console.next')}
162 <ChevronRight className="h-3.5 w-3.5" />
163 </Button>
164 </span>
165 </div>
166
167 {tasks.length > 0 && total === 0 ? (
168 <Button variant="ghost" className="self-start" onClick={onCreate}>
169 {t('agent.console.new')}
170 </Button>
171 ) : null}
172 </div>
173 )
174}
No newline at end of file
Added web/src/features/agent-console/ui/agent-detail.tsx web/src/features/agent-console/ui/agent-detail.tsx +273 −0 View web/src/features/agent-console/ui/agent-detail.tsx
@@ -0,0 +1,273 @@
1/**
2 * Task detail panel.
3 *
4 * The body is editable only while the task is a draft (the backend freezes it
5 * once a run starts), so the panel reads `state` to decide which actions exist
6 * instead of letting the user click into a 409. Everything else — messages and
7 * the activity feed — is a read-only mirror of the gateway session.
8 */
9import { useState } from 'react'
10import { ArrowLeft, Bot, CirclePlay, Pencil, Square, Trash2, Undo2, User } from 'lucide-react'
11import { Alert, Button, ErrorPanel, Markdown } from '@/shared/ui'
12import { useSave } from '@/shared/ui'
13import { useT } from '@/shared/i18n'
14import { fmtRelative, fmtTime } from '@/shared/lib'
15import { peekCache, writeCache } from '@/shared/storage'
16import {
17 cancelTask,
18 closeTask,
19 eventLabel,
20 describeEventContent,
21 isActiveState,
22 priorityOf,
23 reopenTask,
24 ROLE_LABELS,
25 setTaskWatch,
26 startTask,
27 stateOf,
28 WATCH_LABELS,
29 WATCH_MODES,
30 type AgentRef,
31 type WatchMode,
32} from '@/entities/agent'
33import type { AgentConsoleData } from '../model/use-agent-console-data'
34
35export interface AgentDetailProps {
36 agentRef: AgentRef
37 data: AgentConsoleData
38 onEdit: () => void
39 onChanged: () => void
40 onClose: () => void
41 onDelete: () => void
42}
43
44export function AgentDetail({ agentRef, data, onEdit, onChanged, onClose, onDelete }: AgentDetailProps) {
45 const { t } = useT()
46 const save = useSave()
47 const { task, messages, events } = data
48
49 // There is no read endpoint for the watch row, so the panel remembers the
50 // last mode this browser set and shows that; the server stays authoritative.
51 const watchCacheKey = `agent-watch:${agentRef.space}/${agentRef.collection}/${task?.id ?? ''}`
52 const [watch, setWatch] = useState<WatchMode>(() => peekCache<WatchMode>(watchCacheKey)?.data ?? 'unwatched')
53
54 if (!task) {
55 if (data.taskFailure) {
56 return (
57 <article className="rounded-lg border-2 border-shell-hairline bg-shell-surface p-6">
58 <ErrorPanel
59 status={data.taskFailure.code}
60 what={t('agent.console.errorTask')}
61 note={data.taskFailure.message}
62 onRetry={onChanged}
63 />
64 </article>
65 )
66 }
67 return (
68 <article className="flex flex-col gap-2 rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
69 <span className="h-5 w-1/2 rounded-full bg-shell-field" />
70 <span className="h-4 w-1/3 rounded-full bg-shell-field" />
71 <span className="h-24 w-full rounded-md bg-shell-field" />
72 </article>
73 )
74 }
75
76 const meta = stateOf(task.state)
77 const priority = priorityOf(task.priority)
78 const active = isActiveState(task.state)
79 const closed = task.state === 'closed'
80 const started = Boolean(task.started_at)
81
82 const run = async (action: 'start' | 'cancel' | 'close' | 'reopen') => {
83 await save.run(async () => {
84 if (action === 'start') await startTask(agentRef, task.id)
85 else if (action === 'cancel') await cancelTask(agentRef, task.id)
86 else if (action === 'close') await closeTask(agentRef, task.id)
87 else await reopenTask(agentRef, task.id)
88 onChanged()
89 })
90 }
91
92 const changeWatch = async (mode: WatchMode) => {
93 setWatch(mode)
94 writeCache(watchCacheKey, mode)
95 await save.run(() => setTaskWatch(agentRef, task.id, mode))
96 }
97
98 return (
99 <article className="flex flex-col gap-4 rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
100 <header className="flex items-start gap-2">
101 <button
102 type="button"
103 onClick={onClose}
104 title={t('agent.console.task.back')}
105 aria-label={t('agent.console.task.back')}
106 className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-shell-hairline text-shell-ink-muted hover:border-shell-ink-muted"
107 >
108 <ArrowLeft className="h-3.5 w-3.5" />
109 </button>
110 <div className="flex min-w-0 flex-1 flex-col gap-1.5">
111 <div className="flex items-center gap-2">
112 <span className="font-mono text-[12px] text-shell-ink-muted">{`#${task.number}`}</span>
113 <span className="text-[15px] font-semibold">{task.title}</span>
114 </div>
115 <div className="flex flex-wrap items-center gap-2">
116 <span
117 className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
118 style={{ backgroundColor: meta.color }}
119 >
120 {meta.label}
121 </span>
122 <span
123 className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
124 style={{ backgroundColor: priority.color, color: priority.text }}
125 >
126 {priority.label}
127 </span>
128 {task.due_at ? (
129 <span className="text-[11.5px] text-shell-ink-muted">
130 {t('agent.console.form.dueAt')}
131 {` ${fmtTime(task.due_at)}`}
132 </span>
133 ) : null}
134 </div>
135 </div>
136 </header>
137
138 <div className="flex flex-wrap items-center gap-2">
139 {task.state === 'draft' || task.state === 'pending' ? (
140 <Button className="h-7 px-3 text-[12.5px]" disabled={save.busy} onClick={() => run('start')}>
141 <CirclePlay className="h-3.5 w-3.5" />
142 {t('agent.console.task.start')}
143 </Button>
144 ) : null}
145 {active ? (
146 <Button
147 variant="secondary"
148 className="h-7 px-3 text-[12.5px]"
149 disabled={save.busy}
150 onClick={() => run('cancel')}
151 >
152 <Square className="h-3.5 w-3.5" />
153 {t('agent.console.task.cancel')}
154 </Button>
155 ) : null}
156 {closed ? (
157 <Button
158 variant="secondary"
159 className="h-7 px-3 text-[12.5px]"
160 disabled={save.busy}
161 onClick={() => run('reopen')}
162 >
163 <Undo2 className="h-3.5 w-3.5" />
164 {t('agent.console.task.reopen')}
165 </Button>
166 ) : (
167 <Button
168 variant="secondary"
169 className="h-7 px-3 text-[12.5px]"
170 disabled={save.busy}
171 onClick={() => run('close')}
172 >
173 <Square className="h-3.5 w-3.5" />
174 {t('agent.console.task.close')}
175 </Button>
176 )}
177 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={onEdit}>
178 <Pencil className="h-3.5 w-3.5" />
179 {t('agent.console.task.edit')}
180 </Button>
181 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={onDelete}>
182 <Trash2 className="h-3.5 w-3.5" />
183 {t('agent.console.task.delete')}
184 </Button>
185 <label className="ml-auto flex items-center gap-1 text-[12px] text-shell-ink-muted">
186 {t('agent.console.task.watch')}
187 <select
188 value={watch}
189 onChange={event => void changeWatch(event.target.value as WatchMode)}
190 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"
191 >
192 {WATCH_MODES.map(mode => (
193 <option key={mode} value={mode}>
194 {WATCH_LABELS[mode]}
195 </option>
196 ))}
197 </select>
198 </label>
199 </div>
200
201 {save.error ? <Alert>{`${t('agent.console.actionFailed')}: ${save.error}`}</Alert> : null}
202 {!started && (task.state === 'draft' || task.state === 'pending') ? (
203 <p className="rounded-md bg-shell-field px-3 py-2 text-[12px] text-shell-ink-muted">
204 {t('agent.console.task.startHint')}
205 </p>
206 ) : null}
207
208 <section className="flex flex-col gap-1.5">
209 <h3 className="text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
210 {t('agent.console.task.body')}
211 </h3>
212 {task.body ? (
213 <div className="rounded-md border-2 border-shell-hairline px-3 py-2">
214 <Markdown>{task.body}</Markdown>
215 </div>
216 ) : (
217 <p className="text-[12.5px] text-shell-ink-muted">{t('agent.console.task.noBody')}</p>
218 )}
219 {started ? <p className="text-[11.5px] text-shell-ink-muted">{t('agent.console.task.bodyFrozen')}</p> : null}
220 </section>
221
222 <section className="flex flex-col gap-2">
223 <h3 className="text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
224 {t('agent.console.task.conversation')}
225 </h3>
226 {messages.length === 0 ? (
227 <p className="text-[12.5px] text-shell-ink-muted">{t('agent.console.task.noMessages')}</p>
228 ) : (
229 <ol className="flex flex-col gap-2">
230 {messages.map(message => (
231 <li key={message.id} className="flex gap-2">
232 <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-shell-field text-shell-ink-muted">
233 {message.role === 'user' ? <User className="h-3 w-3" /> : <Bot className="h-3 w-3" />}
234 </span>
235 <div className="flex min-w-0 flex-1 flex-col gap-0.5">
236 <span className="flex items-center gap-2 text-[11.5px] text-shell-ink-muted">
237 <span className="font-semibold">{ROLE_LABELS[message.role] ?? message.role}</span>
238 <span>{fmtRelative(message.created_at)}</span>
239 <span className="font-mono">{`#${message.seq}`}</span>
240 </span>
241 <div className="text-[12.5px] leading-[1.6]">
242 <Markdown>{message.content}</Markdown>
243 </div>
244 </div>
245 </li>
246 ))}
247 </ol>
248 )}
249 </section>
250
251 <section className="flex flex-col gap-2">
252 <h3 className="text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
253 {t('agent.console.task.activity')}
254 </h3>
255 {events.length === 0 ? (
256 <p className="text-[12.5px] text-shell-ink-muted">{t('agent.console.task.noEvents')}</p>
257 ) : (
258 <ol className="flex flex-col gap-1.5">
259 {events.map(event => (
260 <li key={event.id} className="flex items-baseline gap-2 text-[12px]">
261 <span className="font-mono text-[11px] text-shell-ink-muted">{fmtRelative(event.created_at)}</span>
262 <span>{eventLabel(event.event_type)}</span>
263 {describeEventContent(event.content) ? (
264 <span className="text-shell-ink-muted">{describeEventContent(event.content)}</span>
265 ) : null}
266 </li>
267 ))}
268 </ol>
269 )}
270 </section>
271 </article>
272 )
273}
No newline at end of file
Added web/src/features/agent-console/ui/agent-filters.tsx web/src/features/agent-console/ui/agent-filters.tsx +123 −0 View web/src/features/agent-console/ui/agent-filters.tsx
@@ -0,0 +1,123 @@
1/**
2 * Toolbar of the 智能体 console: the server-side filters (state, priority, sort,
3 * full-text), the layout switch and the primary action. Every control writes
4 * through `onFilter`, which patches the URL — so a filtered view is
5 * shareable and survives a reload, and there is no client-side filtering.
6 */
7import { LayoutGrid, List, Plus, RotateCcw, Search } from 'lucide-react'
8import { Button } from '@/shared/ui'
9import { useT } from '@/shared/i18n'
10import { PRIORITIES, SORTS, STATE_FILTERS } from '@/entities/agent'
11import type { AgentConsoleView } from '../model/query'
12
13export interface AgentFiltersProps {
14 view: AgentConsoleView
15 onFilter: (patch: Partial<AgentConsoleView>) => void
16 onClear: () => void
17}
18
19const controlCls =
20 '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'
21
22export function AgentFilters({ view, onFilter, onClear }: AgentFiltersProps) {
23 const { t } = useT()
24 const filtered = view.state !== 'all' || view.q !== '' || view.priority !== ''
25 return (
26 <>
27 <div className="flex flex-wrap items-center gap-1.5">
28 {STATE_FILTERS.map(item => (
29 <button
30 key={item.id}
31 type="button"
32 onClick={() => onFilter({ state: item.id, page: 1 })}
33 className={`h-7 rounded-md border-2 px-2.5 text-[12.5px] transition-colors ${
34 view.state === item.id
35 ? 'border-shell-ink bg-shell-tile font-semibold'
36 : 'border-shell-hairline hover:border-shell-ink-muted'
37 }`}
38 >
39 {item.label}
40 </button>
41 ))}
42 </div>
43
44 <label className="relative flex items-center">
45 <Search className="pointer-events-none absolute left-2 h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
46 <input
47 value={view.q}
48 onChange={event => onFilter({ q: event.target.value, page: 1 })}
49 placeholder={t('agent.console.filter.search')}
50 aria-label={t('agent.console.filter.search')}
51 className={`${controlCls} w-44 pl-7`}
52 />
53 </label>
54
55 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
56 {t('agent.console.filter.priority')}
57 <select
58 value={view.priority}
59 onChange={event => onFilter({ priority: event.target.value as AgentConsoleView['priority'], page: 1 })}
60 className={controlCls}
61 >
62 <option value="">—</option>
63 {PRIORITIES.map(meta => (
64 <option key={meta.id} value={meta.id}>
65 {meta.label}
66 </option>
67 ))}
68 </select>
69 </label>
70
71 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
72 {t('agent.console.filter.sort')}
73 <select
74 value={view.sort}
75 onChange={event => onFilter({ sort: event.target.value as AgentConsoleView['sort'], page: 1 })}
76 className={controlCls}
77 >
78 {SORTS.map(meta => (
79 <option key={meta.id} value={meta.id}>
80 {meta.label}
81 </option>
82 ))}
83 </select>
84 </label>
85
86 {filtered ? (
87 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={onClear}>
88 <RotateCcw className="h-3.5 w-3.5" />
89 {t('agent.console.filter.reset')}
90 </Button>
91 ) : null}
92 </>
93 )
94}
95
96export interface AgentActionsProps {
97 view: AgentConsoleView
98 onFilter: (patch: Partial<AgentConsoleView>) => void
99 onCreate: () => void
100}
101
102export function AgentActions({ view, onFilter, onCreate }: AgentActionsProps) {
103 const { t } = useT()
104 const next = view.layout === 'cards' ? 'list' : 'cards'
105 return (
106 <>
107 <button
108 type="button"
109 onClick={() => onFilter({ layout: next })}
110 title={t(next === 'list' ? 'agent.console.viewList' : 'agent.console.viewCards')}
111 aria-label={t(next === 'list' ? 'agent.console.viewList' : 'agent.console.viewCards')}
112 className="flex h-7 items-center gap-1.5 rounded-md border-2 border-shell-hairline px-2 text-[12.5px] transition-colors hover:border-shell-ink-muted"
113 >
114 {view.layout === 'cards' ? <LayoutGrid className="h-3.5 w-3.5" /> : <List className="h-3.5 w-3.5" />}
115 {t(view.layout === 'cards' ? 'agent.console.viewCards' : 'agent.console.viewList')}
116 </button>
117 <Button className="h-7 px-3 text-[12.5px]" onClick={onCreate}>
118 <Plus className="h-3.5 w-3.5" />
119 {t('agent.console.new')}
120 </Button>
121 </>
122 )
123}
No newline at end of file
Added web/src/features/agent-console/ui/agent-form-modal.tsx web/src/features/agent-console/ui/agent-form-modal.tsx +122 −0 View web/src/features/agent-console/ui/agent-form-modal.tsx
@@ -0,0 +1,122 @@
1/**
2 * Create / edit a 智能体任务.
3 *
4 * The backend freezes `body` once a run has started (`Conflict`), so the field
5 * is only submitted while the task is still a draft; the rest of the form
6 * always applies. Empty optional fields are sent as `null` to clear them —
7 * `undefined` would mean “leave alone”, which is not what a blank input means.
8 */
9import { useMemo } from 'react'
10import { FormModal, type FormField, type FormValues } from '@/shared/ui'
11import { useT } from '@/shared/i18n'
12import {
13 createTask,
14 dateApiValue,
15 dateInputValue,
16 PRIORITIES,
17 updateTask,
18 type AgentPriority,
19 type AgentRef,
20 type AgentTask,
21} from '@/entities/agent'
22
23export interface AgentFormModalProps {
24 agentRef: AgentRef
25 /** `null` creates a new task. */
26 task: AgentTask | null
27 open: boolean
28 onSaved: (task: AgentTask) => void
29 onClose: () => void
30}
31
32export function AgentFormModal({ agentRef, task, open, onSaved, onClose }: AgentFormModalProps) {
33 const { t } = useT()
34
35 const fields = useMemo<FormField[]>(
36 () => [
37 {
38 kind: 'text',
39 key: 'title',
40 label: t('agent.console.form.title'),
41 placeholder: t('agent.console.form.titlePlaceholder'),
42 maxLength: 255,
43 initialValue: task?.title ?? '',
44 },
45 {
46 kind: 'textarea',
47 key: 'description',
48 label: t('agent.console.form.description'),
49 optional: true,
50 rows: 2,
51 maxLength: 1024,
52 initialValue: task?.description ?? '',
53 },
54 {
55 kind: 'textarea',
56 key: 'body',
57 label: t('agent.console.form.body'),
58 hint: task && task.state !== 'draft' ? t('agent.console.task.bodyFrozen') : undefined,
59 optional: true,
60 rows: 10,
61 initialValue: task?.body ?? '',
62 },
63 {
64 kind: 'select',
65 key: 'priority',
66 label: t('agent.console.form.priority'),
67 options: PRIORITIES.map(meta => ({ value: meta.id, label: meta.label })),
68 initialValue: task?.priority ?? 'none',
69 },
70 {
71 kind: 'datetime',
72 key: 'due_at',
73 label: t('agent.console.form.dueAt'),
74 optional: true,
75 initialValue: dateInputValue(task?.due_at),
76 },
77 ],
78 [task, t],
79 )
80
81 async function submit(values: FormValues) {
82 const title = String(values.title ?? '').trim()
83 if (!title) throw new Error(t('agent.console.form.titleRequired'))
84 const description = String(values.description ?? '').trim()
85 const body = String(values.body ?? '')
86 const priority = String(values.priority ?? 'none') as AgentPriority
87 const due = String(values.due_at ?? '')
88 const dueAt = due ? dateApiValue(due) : null
89
90 if (task) {
91 const saved = await updateTask(agentRef, task.id, {
92 title,
93 description: description || null,
94 due_at: dueAt,
95 priority,
96 ...(task.state === 'draft' ? { body } : {}),
97 })
98 onSaved(saved)
99 } else {
100 const saved = await createTask(agentRef, {
101 title,
102 description: description || undefined,
103 body,
104 priority,
105 due_at: dueAt,
106 })
107 onSaved(saved)
108 }
109 }
110
111 return (
112 <FormModal
113 title={t(task ? 'agent.console.form.editTitle' : 'agent.console.form.createTitle')}
114 open={open}
115 wide
116 fields={fields}
117 confirmLabel={t(task ? 'common.action.save' : 'common.action.create')}
118 onSubmit={submit}
119 onClose={onClose}
120 />
121 )
122}
No newline at end of file
Added web/src/features/agent-console/ui/collection-manage-modal.tsx web/src/features/agent-console/ui/collection-manage-modal.tsx +116 −0 View web/src/features/agent-console/ui/collection-manage-modal.tsx
@@ -0,0 +1,116 @@
1/**
2 * Rename / describe / delete a 智能体集.
3 *
4 * Writes go out on save (not on close), and the delete is a two-step confirm
5 * inside the panel: deleting the collection drops every task in it, so it must
6 * not be a single stray click.
7 */
8import { useState } from 'react'
9import { Button, Modal, modalInputClass, modalLabelClass, modalTextareaClass, useSave } from '@/shared/ui'
10import { Alert } from '@/shared/ui'
11import { useT } from '@/shared/i18n'
12import { deleteCollection, updateCollection, type AgentCollection, type AgentRef } from '@/entities/agent'
13
14export interface CollectionManageModalProps {
15 agentRef: AgentRef
16 collection?: AgentCollection
17 open: boolean
18 onSaved: () => void
19 onDeleted: () => void
20 onClose: () => void
21}
22
23export function CollectionManageModal({ agentRef, collection, open, onSaved, onDeleted, onClose }: CollectionManageModalProps) {
24 const { t } = useT()
25 const save = useSave()
26 const [name, setName] = useState(collection?.name ?? '')
27 const [description, setDescription] = useState(collection?.description ?? '')
28 const [icon, setIcon] = useState(collection?.icon ?? '')
29 const [confirming, setConfirming] = useState(false)
30
31 const dirty =
32 Boolean(collection) &&
33 (name.trim() !== collection?.name || description.trim() !== (collection?.description ?? '') || icon.trim() !== (collection?.icon ?? ''))
34
35 async function submit() {
36 if (!collection || !name.trim()) return
37 await save.run(async () => {
38 await updateCollection(agentRef.space, agentRef.collection, {
39 name: name.trim(),
40 description: description.trim() || null,
41 icon: icon.trim() || null,
42 })
43 onSaved()
44 })
45 }
46
47 async function remove() {
48 if (!collection) return
49 await save.run(async () => {
50 await deleteCollection(agentRef.space, agentRef.collection)
51 onDeleted()
52 })
53 }
54
55 return (
56 <Modal open={open} onClose={onClose} title={t('agent.console.manage')} width="max-w-[560px]">
57 <div className="space-y-4">
58 {save.error ? <Alert>{save.error}</Alert> : null}
59
60 <label className="block space-y-1.5">
61 <span className={modalLabelClass}>{t('space.agent.nameField')}</span>
62 <input className={modalInputClass} value={name} onChange={event => setName(event.target.value)} maxLength={100} />
63 </label>
64
65 <label className="block space-y-1.5">
66 <span className={modalLabelClass}>{t('space.agent.description')}</span>
67 <textarea
68 className={modalTextareaClass}
69 value={description}
70 onChange={event => setDescription(event.target.value)}
71 rows={3}
72 maxLength={512}
73 />
74 </label>
75
76 <label className="block space-y-1.5">
77 <span className={modalLabelClass}>{t('space.agent.icon')}</span>
78 <input className={modalInputClass} value={icon} onChange={event => setIcon(event.target.value)} maxLength={16} />
79 </label>
80
81 <p className="text-[12px] text-shell-ink-muted">
82 {t('agent.console.manage.stats', {
83 running: collection?.running_tasks ?? 0,
84 total: collection?.total_tasks ?? 0,
85 })}
86 </p>
87
88 <div className="flex items-center justify-between gap-3 border-t-2 border-shell-hairline pt-4">
89 {confirming ? (
90 <span className="flex items-center gap-2">
91 <span className="text-[12.5px] text-shell-ink-muted">{t('agent.console.manage.deleteConfirm')}</span>
92 <Button variant="secondary" className="h-7 px-2 text-[12.5px]" onClick={() => setConfirming(false)}>
93 {t('common.action.cancel')}
94 </Button>
95 <Button className="h-7 px-2 text-[12.5px]" disabled={save.busy} onClick={() => void remove()}>
96 {t('common.action.delete')}
97 </Button>
98 </span>
99 ) : (
100 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={() => setConfirming(true)}>
101 {t('common.action.delete')}
102 </Button>
103 )}
104 <span className="ml-auto flex items-center gap-2">
105 <Button variant="secondary" className="h-7 px-3 text-[12.5px]" onClick={onClose}>
106 {t('common.action.close')}
107 </Button>
108 <Button className="h-7 px-3 text-[12.5px]" disabled={!dirty || save.busy} onClick={() => void submit()}>
109 {save.busy ? t('common.action.working') : t('common.action.save')}
110 </Button>
111 </span>
112 </div>
113 </div>
114 </Modal>
115 )
116}
No newline at end of file
Added web/src/features/agent-console/ui/parts.tsx web/src/features/agent-console/ui/parts.tsx +87 −0 View web/src/features/agent-console/ui/parts.tsx
@@ -0,0 +1,87 @@
1/**
2 * Small render pieces for the 智能体 console: badges and task cards. They own no
3 * data — every value comes from `@/entities/agent` metadata so the cards, the
4 * table and the detail panel render the same state the same way.
5 */
6import type { ReactNode } from 'react'
7import { Loader2 } from 'lucide-react'
8import { fmtRelative } from '@/shared/lib'
9import { useT } from '@/shared/i18n'
10import {
11 isActiveState,
12 priorityOf,
13 stateOf,
14 type AgentPriority,
15 type AgentState,
16 type AgentTask,
17} from '@/entities/agent'
18
19export function StateBadge({ state }: { state: AgentState | string }) {
20 const meta = stateOf(state)
21 return (
22 <span
23 className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
24 style={{ backgroundColor: meta.color }}
25 >
26 {isActiveState(state) ? <Loader2 size={10} className="animate-spin" aria-hidden /> : null}
27 {meta.label}
28 </span>
29 )
30}
31
32export function PriorityBadge({ priority, compact = false }: { priority: AgentPriority; compact?: boolean }) {
33 const meta = priorityOf(priority)
34 return (
35 <span
36 className={`inline-flex shrink-0 items-center rounded-full font-semibold ${compact ? 'px-1.5 py-px text-[10.5px]' : 'px-2 py-0.5 text-[11px]'}`}
37 style={{ backgroundColor: meta.color, color: meta.text }}
38 >
39 {meta.label}
40 </span>
41 )
42}
43
44/** `#12 · 3 分钟前`, the meta line the cards and the table row share. */
45export function TaskMeta({ task }: { task: AgentTask }) {
46 const { t } = useT()
47 return (
48 <span className="flex shrink-0 items-center gap-2 text-[11px] text-shell-ink-muted">
49 <span className="font-mono">{`#${task.number}`}</span>
50 <span>{`· ${t('agent.console.task.createdAt')} ${fmtRelative(task.created_at)}`}</span>
51 </span>
52 )
53}
54
55export function TaskCard({ task, active, onOpen }: { task: AgentTask; active?: boolean; onOpen: () => void }) {
56 return (
57 <button
58 type="button"
59 onClick={onOpen}
60 className={`flex w-full flex-col gap-2 rounded-lg border-2 bg-shell-surface p-3 text-left transition-colors ${
61 active ? 'border-shell-ink' : 'border-shell-hairline hover:border-shell-ink-muted'
62 }`}
63 >
64 <div className="flex items-start justify-between gap-2">
65 <span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold">{task.title}</span>
66 <PriorityBadge priority={task.priority} compact />
67 </div>
68 {task.description ? (
69 <p className="line-clamp-2 text-[12px] leading-[1.5] text-shell-ink-muted">{task.description}</p>
70 ) : null}
71 <div className="flex items-center justify-between gap-2">
72 <StateBadge state={task.state} />
73 <TaskMeta task={task} />
74 </div>
75 </button>
76 )
77}
78
79export function ConsoleEmpty({ icon, title, note }: { icon?: ReactNode; title: string; note?: string }) {
80 return (
81 <div className="flex w-full flex-col items-center gap-2 rounded-lg border-2 border-dashed border-shell-hairline px-4 py-12 text-center">
82 {icon}
83 <p className="text-[13.5px] font-semibold">{title}</p>
84 {note ? <p className="text-[12.5px] text-shell-ink-muted">{note}</p> : null}
85 </div>
86 )
87}
No newline at end of file
Added web/src/features/agent-console/index.ts web/src/features/agent-console/index.ts +22 −0 View web/src/features/agent-console/index.ts
@@ -0,0 +1,22 @@
1// Public API of features/agent-console — the 智能体 (sandbox agent) pages:
2// toolbar filters, cards/list body, task detail, and the create/edit/manage
3// dialogs. Consumers import from '@/features/agent-console', never deeper.
4export {
5 AGENT_PAGE_SIZE,
6 AGENT_TASK_PAGE_SIZE,
7 useAgentConsoleData,
8 type AgentConsoleData,
9} from './model/use-agent-console-data'
10export {
11 AGENT_VIEW_DEFAULTS,
12 buildAgentQuery,
13 normalizeAgentLayout,
14 type AgentConsoleView,
15 type AgentLayout,
16} from './model/query'
17export { prefetchAgentConsole } from './model/prefetch'
18export { AgentActions, AgentFilters } from './ui/agent-filters'
19export { AgentConsole } from './ui/agent-console'
20export { AgentDetail } from './ui/agent-detail'
21export { AgentFormModal } from './ui/agent-form-modal'
22export { CollectionManageModal } from './ui/collection-manage-modal'
No newline at end of file
Modified web/src/features/space-management/ui/space-header.tsx web/src/features/space-management/ui/space-header.tsx +47 −3 View web/src/features/space-management/ui/space-header.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
22import {
3 Bot,
34 ChevronDown,
45 Download,
56 FolderGit2,
@@ -24,6 +25,7 @@
2425 updatePreferences,
2526} from '../model/api'
2627import type { MySpace } from '../model/api'
28import { createCollection } from '@/entities/agent'
2729import { ImportRepoModal } from './import-repo-modal'
2830import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
2931import { FormModal, parseList } from '@/shared/ui'
@@ -36,6 +38,7 @@
3638 | 'repo'
3739 | 'import'
3840 | 'board'
41 | 'agent'
3942 | 'room'
4043 | 'dir'
4144 | 'privacy'
@@ -121,13 +124,14 @@
121124 setDialog(next)
122125 }
123126
124 // Parent pickers (folder/repo/board/room) need fresh tree data on each open.
127 // Parent pickers (folder/repo/board/agent/room) need fresh tree data on each open.
125128 useEffect(() => {
126129 if (
127130 dialog !== 'dir' &&
128131 dialog !== 'repo' &&
129132 dialog !== 'import' &&
130133 dialog !== 'board' &&
134 dialog !== 'agent' &&
131135 dialog !== 'room'
132136 )
133137 return
@@ -221,6 +225,7 @@
221225 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
222226 <MenuItem icon={Download} label={t('space.import')} onClick={() => openDialog('import')} />
223227 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
228 <MenuItem icon={Bot} label={t('space.menu.createAgent')} onClick={() => openDialog('agent')} />
224229 {perms.has('manage_channels') ? (
225230 <MenuItem icon={Hash} label={t('space.menu.createRoom')} onClick={() => openDialog('room')} />
226231 ) : null}
@@ -405,9 +410,48 @@
405410 onClose={closeDialog}
406411 />
407412 <FormModal
413 title={t('space.agent.create.title')}
414 open={dialog === 'agent'}
415 confirmLabel={t('common.action.create')}
416 fields={[
417 { kind: 'text', key: 'name', label: t('space.agent.nameField'), placeholder: 'triage-bot' },
418 {
419 kind: 'textarea',
420 key: 'description',
421 label: t('space.agent.description'),
422 optional: true,
423 maxLength: 512,
424 },
425 {
426 kind: 'text',
427 key: 'icon',
428 label: t('space.agent.icon'),
429 placeholder: '',
430 optional: true,
431 maxLength: 16,
432 },
433 {
434 kind: 'select',
435 key: 'parent_dir_id',
436 label: t('space.repo.dir'),
437 initialValue: '',
438 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
439 },
440 ]}
441 onSubmit={async values => {
442 await createCollection(space.name, {
443 name: String(values.name),
444 description: String(values.description).trim() || undefined,
445 icon: String(values.icon).trim() || undefined,
446 parent_dir_id: String(values.parent_dir_id) || undefined,
447 })
448 onListsChanged()
449 }}
450 onClose={closeDialog}
451 />
452 <FormModal
408453 title={t('space.room.title')}
409 open={dialog === 'room'}
410 confirmLabel={t('common.action.create')}
454 open={dialog === 'room'} confirmLabel={t('common.action.create')}
411455 fields={[
412456 {
413457 kind: 'text',
Added web/src/pages/workspace/model/agent-location.ts web/src/pages/workspace/model/agent-location.ts +122 −0 View web/src/pages/workspace/model/agent-location.ts
@@ -0,0 +1,122 @@
1import { useCallback, useMemo } from 'react'
2import { useSearchParams } from 'react-router-dom'
3import {
4 AGENT_VIEW_DEFAULTS,
5 normalizeAgentLayout,
6 type AgentConsoleView,
7} from '@/features/agent-console'
8import { AGENT_STATES, PRIORITIES, SORTS, type AgentPriority, type AgentStateFilter } from '@/entities/agent'
9
10/**
11 * The 智能体 console page state, encoded entirely in the route's query string —
12 * the same contract the issue board uses, so a filtered console is a shareable
13 * link and browser back/forward walks the filters:
14 *
15 * ```text
16 * /app/demo/agent/01a0… → 卡片视图 · 全部状态 · 最新创建
17 * /app/demo/agent/01a0…?layout=list → 列表视图
18 * /app/demo/agent/01a0…?state=executing&sort=updated&q=调研
19 * /app/demo/agent/01a0…?page=3 → 第 3 页
20 * /app/demo/agent/01a0…?task=12 → 任务 #12 详情(含对话与活动)
21 * ```
22 *
23 * Ephemeral state (which modal is open, the composer draft) stays out of the
24 * URL; a task is addressed by its collection-scoped number because that is what
25 * a user reads off the card, and the backend accepts it as a path segment.
26 *
27 * There is no account-level default here (unlike the board's saved view mode):
28 * the console has no preference that would pin a layout, so a plain URL always
29 * renders the same thing.
30 */
31const FILTER_KEYS = ['layout', 'state', 'q', 'priority', 'sort'] as const
32export type AgentFilterKey = (typeof FILTER_KEYS)[number]
33
34const OWNED_KEYS = ['layout', 'state', 'q', 'priority', 'sort', 'page', 'task']
35
36const LAYOUTS = ['cards', 'list'] as const
37const STATES: readonly AgentStateFilter[] = [...AGENT_STATES, 'all']
38
39function pick<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
40 return allowed.includes((raw ?? '') as T) ? (raw as T) : fallback
41}
42
43/** Parse the URL into console state; unknown values fall back to their default. */
44export function readAgentConsoleView(params: URLSearchParams): AgentConsoleView {
45 const rawPage = Number.parseInt(params.get('page') ?? '', 10)
46 return {
47 layout: normalizeAgentLayout(pick(params.get('layout'), LAYOUTS, AGENT_VIEW_DEFAULTS.layout)),
48 state: pick(params.get('state'), STATES, 'all'),
49 q: params.get('q') ?? '',
50 priority: pick(params.get('priority'), PRIORITIES.map(meta => meta.id), '') as AgentPriority | '',
51 sort: pick(params.get('sort'), SORTS.map(meta => meta.id), 'created'),
52 page: Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1,
53 task: params.get('task') ?? '',
54 }
55}
56
57/** Encode state, dropping every default-valued key so plain URLs stay short. */
58export function writeAgentConsoleView(base: URLSearchParams, state: AgentConsoleView): URLSearchParams {
59 const next = new URLSearchParams(base)
60 for (const key of OWNED_KEYS) next.delete(key)
61 if (state.layout !== AGENT_VIEW_DEFAULTS.layout) next.set('layout', state.layout)
62 if (state.state !== 'all') next.set('state', state.state)
63 if (state.q) next.set('q', state.q)
64 if (state.priority) next.set('priority', state.priority)
65 if (state.sort !== 'created') next.set('sort', state.sort)
66 if (state.page > 1) next.set('page', String(state.page))
67 if (state.task) next.set('task', state.task)
68 return next
69}
70
71/**
72 * A filter change returns to page 1: page 3 of a result set that just changed
73 * shape is a different question, and paging past the end would read as "no
74 * results".
75 */
76export function applyAgentViewPatch(current: AgentConsoleView, updates: Partial<AgentConsoleView>): AgentConsoleView {
77 const merged = { ...current, ...updates }
78 const filterChanged = FILTER_KEYS.some(key => updates[key] !== undefined && updates[key] !== current[key])
79 return filterChanged && merged.page === current.page ? { ...merged, page: 1 } : merged
80}
81
82export interface AgentViewLocation {
83 view: AgentConsoleView
84 /** True while at least one filter narrows the result set. */
85 filtered: boolean
86 patch: (updates: Partial<AgentConsoleView>, options?: { replace?: boolean }) => void
87 clearFilters: () => void
88 openTask: (task: string) => void
89 closeTask: () => void
90 /** Drop every owned param when the collection under the same route changes. */
91 normalize: () => void
92}
93
94export function useAgentViewLocation(): AgentViewLocation {
95 const [params, setParams] = useSearchParams()
96 const view = useMemo(() => readAgentConsoleView(params), [params])
97
98 const patch = useCallback<AgentViewLocation['patch']>(
99 (updates, options) => {
100 setParams(prev => writeAgentConsoleView(prev, applyAgentViewPatch(readAgentConsoleView(prev), updates)), {
101 replace: options?.replace ?? false,
102 preventScrollReset: true,
103 })
104 },
105 [setParams],
106 )
107
108 const clearFilters = useCallback(
109 () => patch({ state: 'all', q: '', priority: '', sort: 'created' }),
110 [patch],
111 )
112 const openTask = useCallback<AgentViewLocation['openTask']>(task => patch({ task }), [patch])
113 const closeTask = useCallback(() => patch({ task: '' }), [patch])
114 const normalize = useCallback(
115 () => setParams(prev => writeAgentConsoleView(prev, { ...AGENT_VIEW_DEFAULTS }), { replace: true, preventScrollReset: true }),
116 [setParams],
117 )
118
119 const filtered = view.state !== 'all' || Boolean(view.q) || Boolean(view.priority) || view.sort !== 'created'
120
121 return { view, filtered, patch, clearFilters, openTask, closeTask, normalize }
122}
No newline at end of file
Modified web/src/pages/workspace/model/prefetch.ts web/src/pages/workspace/model/prefetch.ts +9 −3 View web/src/pages/workspace/model/prefetch.ts
@@ -9,6 +9,7 @@
99 dmsKey,
1010 meKey,
1111 prefetch,
12 spaceAgentsKey,
1213 spaceBoardsKey,
1314 spaceDirKey,
1415 spaceReposKey,
@@ -18,19 +19,21 @@
1819import { prefetchRoomView } from '@/features/chat'
1920import { prefetchRepoView } from '@/features/repo-browse'
2021import { prefetchBoardView } from '@/features/issue-board'
22import { prefetchAgentConsole } from '@/features/agent-console'
2123import { fetchSpaceDir } from '@/features/space-management'
2224import type { RoomBrief } from '@/entities/chat'
2325import {
2426 fetchDms,
2527 fetchMe,
2628 fetchMySpaces,
29 fetchSpaceAgents,
2730 fetchSpaceBoards,
2831 fetchSpaceRepos,
2932 fetchSpaceRooms,
3033} from './shell-api'
31import type { BoardItem, RepoItem } from './workspace'
34import type { AgentCollectionItem, BoardItem, RepoItem } from './workspace'
3235
33export { prefetchRoomView, prefetchRepoView, prefetchBoardView }
36export { prefetchRoomView, prefetchRepoView, prefetchBoardView, prefetchAgentConsole }
3437
3538/**
3639 * Warm the shell's identity and global lists as early as the app boots, in
@@ -45,13 +48,14 @@
4548 void prefetch(dmsKey(), fetchDms)
4649}
4750
48/** Rail hover: the four sidebar lists of the space about to be opened. */
51/** Rail hover: the five sidebar lists of the space about to be opened. */
4952export function prefetchSpaceLists(spaceName: string): void {
5053 if (!spaceName || spaceName === '@me') return
5154 void prefetch(spaceRoomsKey(spaceName), () => fetchSpaceRooms(spaceName))
5255 void prefetch(spaceDirKey(spaceName), () => fetchSpaceDir(spaceName))
5356 void prefetch(spaceReposKey(spaceName), () => fetchSpaceRepos(spaceName))
5457 void prefetch(spaceBoardsKey(spaceName), () => fetchSpaceBoards(spaceName))
58 void prefetch(spaceAgentsKey(spaceName), () => fetchSpaceAgents(spaceName))
5559}
5660
5761/** How many rooms the idle warm-up may prefetch per space. */
@@ -77,6 +81,7 @@
7781 rooms: RoomBrief[],
7882 repos: RepoItem[],
7983 boards: BoardItem[],
84 agents: AgentCollectionItem[] = [],
8085): void {
8186 onIdle(() => {
8287 const ranked = rooms
@@ -95,5 +100,6 @@
95100 for (const room of ranked) prefetchRoomView(room.id)
96101 if (repos[0]) void prefetchRepoView(spaceName, repos[0].name)
97102 if (boards[0]) prefetchBoardView(spaceName, boards[0].id)
103 if (agents[0]) prefetchAgentConsole(spaceName, agents[0].id)
98104 })
99105}
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +2 −1 View web/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'notify' | 'explore'
2export type ShellMode = 'chat' | 'board' | 'repo' | 'agent' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId =
@@ -20,4 +20,5 @@
2020 roomId?: string
2121 boardId?: string
2222 repoName?: string
23 collectionId?: string
2324}
Modified web/src/pages/workspace/model/shell-api.ts web/src/pages/workspace/model/shell-api.ts +10 −0 View web/src/pages/workspace/model/shell-api.ts
@@ -9,6 +9,7 @@
99import { writeCache } from '@/shared/storage'
1010import { fetchSessionUser } from '@/features/auth'
1111import type {
12 AgentCollectionItem,
1213 BoardItem,
1314 DmItem,
1415 Me,
@@ -64,6 +65,15 @@
6465 }) as unknown as PromiseLike<ApiEnvelope<{ items: BoardItem[] }>>,
6566 ).then(p => p?.items ?? [])
6667
68// `list_collections` is typed against the generic `Page` schema too.
69export const fetchSpaceAgents = (spaceName: string) =>
70 unwrapApi<{ items: AgentCollectionItem[] }>(
71 Apis.sagent.list_collections({
72 pathParams: { space_name: spaceName },
73 params: { limit: 100 },
74 }) as unknown as PromiseLike<ApiEnvelope<{ items: AgentCollectionItem[] }>>,
75 ).then(p => p?.items ?? [])
76
6777/** First page of tag names for the repo toolbar chip. */
6878export const fetchTags = (spaceName: string, repoName: string) =>
6979 unwrapApi<{ items: TagItem[] }>(
Modified web/src/pages/workspace/model/workspace-data.ts web/src/pages/workspace/model/workspace-data.ts +12 −1 View web/src/pages/workspace/model/workspace-data.ts
@@ -3,6 +3,7 @@
33 dmsKey,
44 meKey,
55 repoKey,
6 spaceAgentsKey,
67 spaceBoardsKey,
78 spaceDirKey,
89 spaceReposKey,
@@ -17,6 +18,7 @@
1718 fetchDms,
1819 fetchMe,
1920 fetchMySpaces,
21 fetchSpaceAgents,
2022 fetchSpaceBoards,
2123 fetchSpaceRepos,
2224 fetchSpaceRooms,
@@ -33,7 +35,7 @@
3335 * the network revalidation runs, and the fresh response replaces it live.
3436 */
3537export function useWorkspaceData(
36 { spaceName, roomId, boardId, repoName, mode }: ShellRoute,
38 { spaceName, roomId, boardId, repoName, collectionId, mode }: ShellRoute,
3739 reloadKey = 0,
3840 listsReloadKey = 0,
3941 meReloadKey = 0,
@@ -64,6 +66,13 @@
6466 [spaceName, listsReloadKey],
6567 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
6668 )
69 // Smart-agent collections live beside boards in the same dir tree, so the
70 // sidebar needs both lists to decide what a dir node opens.
71 const agents = useAsync(
72 () => (scoped ? fetchSpaceAgents(spaceName) : Promise.resolve([])),
73 [spaceName, listsReloadKey],
74 { cacheKey: scoped ? spaceAgentsKey(spaceName) : null },
75 )
6776 const repoRef = mode === 'repo' && repoName ? `${spaceName}/${repoName}` : ''
6877 const tags = useAsync(
6978 () => (repoRef ? fetchTags(spaceName, repoName!) : Promise.resolve([])),
@@ -97,12 +106,14 @@
97106 dms,
98107 repos,
99108 boards,
109 agents,
100110 tags,
101111 repoRef,
102112 currentSpace: spaces.data?.find(({ space }) => space.name === spaceName)?.space,
103113 room: rooms.data?.find(r => r.id === roomId),
104114 dm: dms.data?.find(d => d.id === roomId),
105115 board: boards.data?.find(b => b.id === boardId),
116 agentCollection: agents.data?.find(a => a.id === collectionId),
106117 repo: repos.data?.find(r => r.name === repoName),
107118 }
108119}
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +5 −2 View web/src/pages/workspace/model/workspace-route.ts
@@ -2,7 +2,7 @@
22import type { ShellMode, ShellRoute } from './route'
33
44/**
5 * URL shape is `/app/{space_name}/{chat|board|repo}/{id?}` (plus the personal
5 * URL shape is `/app/{space_name}/{chat|board|repo|agent}/{id?}` (plus the personal
66 * `/app/@me/notify` inbox). The section is a static route segment, so it is
77 * read off the pathname, and so is every dynamic segment: the workspace is a
88 * single layout route (see `app/routes/router.tsx`) whose children only declare
@@ -31,7 +31,9 @@
3131 ? 'board'
3232 : section === 'repo'
3333 ? 'repo'
34 : 'chat'
34 : section === 'agent'
35 ? 'agent'
36 : 'chat'
3537 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
3638 // data loading and the rail can treat it like any other scope.
3739 const personal = pathname === '/app/@me' || pathname.startsWith('/app/@me/')
@@ -42,5 +44,6 @@
4244 roomId: section === 'chat' ? decode(rawId) : undefined,
4345 boardId: section === 'board' ? decode(rawId) : undefined,
4446 repoName: section === 'repo' ? decode(rawId) : undefined,
47 collectionId: section === 'agent' ? decode(rawId) : undefined,
4548 }
4649}
Modified web/src/pages/workspace/model/workspace.ts web/src/pages/workspace/model/workspace.ts +3 −0 View web/src/pages/workspace/model/workspace.ts
@@ -5,6 +5,7 @@
55import type { DmBrief, RoomBrief } from '@/entities/chat'
66import type {
77 AppResponseData_BoardDto,
8 AppResponseData_CollectionDto,
89 AppResponseData_MeResp,
910 AppResponseData_InviteDto,
1011 AppResponseData_SpaceDto,
@@ -23,6 +24,8 @@
2324 NonNullable<AppResponseData_Page_RepoSummaryDto['data']>['items']
2425>[number]
2526export type BoardItem = NonNullable<AppResponseData_BoardDto['data']>
27/** Smart-agent collection row: same two-layer shape as a board, own API. */
28export type AgentCollectionItem = NonNullable<AppResponseData_CollectionDto['data']>
2629export type Me = NonNullable<AppResponseData_MeResp['data']>
2730export type SpaceDto = NonNullable<AppResponseData_SpaceDto['data']>
2831export type Invite = NonNullable<AppResponseData_InviteDto['data']>
Added web/src/pages/workspace/ui/agent/agent-view.tsx web/src/pages/workspace/ui/agent/agent-view.tsx +198 −0 View web/src/pages/workspace/ui/agent/agent-view.tsx
@@ -0,0 +1,198 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { Button, Modal } from '@/shared/ui'
4import { t as tr, useT } from '@/shared/i18n'
5import {
6 AgentActions,
7 AgentConsole,
8 AgentDetail,
9 AgentFilters,
10 AgentFormModal,
11 buildAgentQuery,
12 CollectionManageModal,
13 useAgentConsoleData,
14} from '@/features/agent-console'
15import { deleteTask, type AgentCollection, type AgentTask } from '@/entities/agent'
16import { useAgentViewLocation } from '../../model/agent-location'
17import { AgentLayout } from '../layout/agent-layout'
18
19export interface AgentViewProps {
20 spaceName: string
21 collectionId: string
22 /** Row from the sidebar's collection list — titles the frame before the fetch lands. */
23 collectionHint?: AgentCollection
24 shell: { rail?: React.ReactNode; sideHeader?: React.ReactNode; navPinned?: React.ReactNode; nav?: React.ReactNode; sideFooter?: React.ReactNode }
25 /** Sidebar counts come from the shell's own query. */
26 onCountsChanged: () => void
27}
28
29/** Distinguish "wrong link" from "no access" from "backend said no". */
30function collectionGoneMessage(code: number | undefined): string {
31 if (code === 404) return tr('agent.console.notFound')
32 if (code === 403) return tr('agent.console.forbidden')
33 if (code === 401) return tr('error.status.401')
34 return tr('agent.console.loadFailed')
35}
36
37/**
38 * Route-level 智能体 console page. The URL owns the view state (see
39 * `model/agent-location`), so this component keeps no mirror of it: it derives
40 * the request, hands data + callbacks to the feature views and holds the two
41 * dialogs (task form / collection management) as single instances.
42 */
43export function AgentView({ spaceName, collectionId, collectionHint, shell, onCountsChanged }: AgentViewProps) {
44 const { t } = useT()
45 const navigate = useNavigate()
46 const loc = useAgentViewLocation()
47 const { view } = loc
48 const [reload, setReload] = useState(0)
49 const [editing, setEditing] = useState<AgentTask | null>(null)
50 const [creating, setCreating] = useState(false)
51 const [managing, setManaging] = useState(false)
52 const [deleting, setDeleting] = useState<AgentTask | null>(null)
53 const [busy, setBusy] = useState(false)
54
55 const ref = useMemo(() => ({ space: spaceName, collection: collectionId }), [spaceName, collectionId])
56 const query = useMemo(() => buildAgentQuery(view), [view])
57 const data = useAgentConsoleData(ref, query, view.task || null, reload)
58
59 const onChanged = useCallback(() => {
60 setReload(key => key + 1)
61 onCountsChanged()
62 }, [onCountsChanged])
63
64 // Switching collection inside the same route component must not carry the
65 // previous collection's filters or `?task=` over; a fresh mount keeps the URL
66 // as typed.
67 const lastCollection = useRef<string | null>(null)
68 useEffect(() => {
69 if (lastCollection.current !== null && lastCollection.current !== collectionId) loc.normalize()
70 lastCollection.current = collectionId
71 // eslint-disable-next-line react-hooks/exhaustive-deps
72 }, [collectionId])
73
74 const collection = data.collection ?? collectionHint
75 const collectionFailure = data.collectionFailure
76
77 if (!collectionId) {
78 return (
79 <AgentLayout {...shell} title={t('agent.console.title')}>
80 <div className="w-full rounded-lg border-2 border-dashed border-shell-hairline px-4 py-16 text-center text-[13px] text-shell-ink-muted">
81 {t('agent.console.pick')}
82 </div>
83 </AgentLayout>
84 )
85 }
86
87 // The collection is gone (deleted elsewhere) or was never visible: nothing
88 // below it can work, so replace the whole content column.
89 if (collectionFailure && !collection) {
90 return (
91 <AgentLayout {...shell} title={t('agent.console.title')}>
92 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-16 text-center">
93 <p className="text-[15px] font-semibold">{collectionGoneMessage(collectionFailure.code)}</p>
94 <p className="max-w-sm text-[12.5px] text-shell-ink-muted">
95 {collectionFailure.message ?? t('agent.console.deletedHint')}
96 </p>
97 <div className="flex items-center gap-2">
98 <Button variant="secondary" onClick={() => setReload(key => key + 1)}>
99 {t('common.action.retry')}
100 </Button>
101 <Button onClick={() => navigate(`/app/${encodeURIComponent(spaceName)}`)}>
102 {t('agent.console.return')}
103 </Button>
104 </div>
105 </div>
106 </AgentLayout>
107 )
108 }
109
110 async function confirmDelete() {
111 if (!deleting) return
112 setBusy(true)
113 try {
114 await deleteTask(ref, deleting.id)
115 setDeleting(null)
116 loc.closeTask()
117 onChanged()
118 } finally {
119 setBusy(false)
120 }
121 }
122
123 return (
124 <>
125 <AgentLayout
126 {...shell}
127 title={collection?.name ?? t('agent.console.title')}
128 topic={collection?.description ?? undefined}
129 toolbar={<AgentFilters view={view} onFilter={loc.patch} onClear={loc.clearFilters} />}
130 toolbarActions={
131 <AgentActions view={view} onFilter={loc.patch} onCreate={() => setCreating(true)} />
132 }
133 >
134 {view.task ? (
135 <AgentDetail
136 agentRef={ref}
137 data={data}
138 onEdit={() => setEditing(data.task ?? null)}
139 onChanged={onChanged}
140 onClose={loc.closeTask}
141 onDelete={() => setDeleting(data.task ?? null)}
142 />
143 ) : (
144 <AgentConsole
145 data={data}
146 view={view}
147 onFilter={loc.patch}
148 onOpenTask={task => loc.openTask(String(task.number))}
149 onCreate={() => setCreating(true)}
150 onRetry={() => setReload(key => key + 1)}
151 />
152 )}
153 </AgentLayout>
154
155 <AgentFormModal
156 agentRef={ref}
157 task={editing}
158 open={creating || editing !== null}
159 onClose={() => {
160 setCreating(false)
161 setEditing(null)
162 }}
163 onSaved={saved => {
164 const wasEditing = editing !== null
165 setCreating(false)
166 setEditing(null)
167 onChanged()
168 // A new task opens immediately: the author must see what they wrote.
169 if (!wasEditing) loc.openTask(String(saved.number))
170 }}
171 />
172
173 <CollectionManageModal
174 agentRef={ref}
175 collection={collection}
176 open={managing}
177 onClose={() => setManaging(false)}
178 onSaved={onChanged}
179 onDeleted={() => {
180 setManaging(false)
181 navigate(`/app/${encodeURIComponent(spaceName)}`)
182 }}
183 />
184
185 <Modal open={deleting !== null} onClose={() => setDeleting(null)} title={t('agent.console.task.delete')} width="max-w-[440px]">
186 <p className="text-[13px] leading-[1.6]">{t('agent.console.task.deleteConfirm')}</p>
187 <div className="flex justify-end gap-2 pt-4">
188 <Button variant="secondary" onClick={() => setDeleting(null)}>
189 {t('common.action.cancel')}
190 </Button>
191 <Button disabled={busy} onClick={() => void confirmDelete()}>
192 {t('common.action.delete')}
193 </Button>
194 </div>
195 </Modal>
196 </>
197 )
198}
No newline at end of file
Added web/src/pages/workspace/ui/layout/agent-layout.tsx web/src/pages/workspace/ui/layout/agent-layout.tsx +46 −0 View web/src/pages/workspace/ui/layout/agent-layout.tsx
@@ -0,0 +1,46 @@
1import type { ReactNode } from 'react'
2import { Bot } from 'lucide-react'
3import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
4
5/**
6 * 智能体 console main-column layout: top bar + toolbar row + a single
7 * vertically scrolling pane. Unlike the issue board there is no kanban track —
8 * a task is a state, not a column — so both variants share the same shell.
9 */
10
11export interface AgentLayoutProps extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
12 title?: ReactNode
13 topic?: ReactNode
14 actions?: ReactNode
15 /** Toolbar left segment (filters). */
16 toolbar?: ReactNode
17 /** Toolbar right segment (layout switch + primary action). */
18 toolbarActions?: ReactNode
19 children?: ReactNode
20}
21
22export function AgentLayout({
23 title,
24 topic,
25 actions,
26 toolbar,
27 toolbarActions,
28 children,
29 ...shell
30}: AgentLayoutProps) {
31 return (
32 <AppShell {...shell}>
33 <ShellTopBar
34 icon={<Bot className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
35 title={title}
36 topic={topic}
37 actions={actions}
38 />
39 <div className="flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b border-shell-hairline px-4 py-2">
40 {toolbar}
41 <div className="ml-auto flex items-center gap-3">{toolbarActions}</div>
42 </div>
43 <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-3">{children}</div>
44 </AppShell>
45 )
46}
No newline at end of file
Modified web/src/pages/workspace/ui/sidebar-nav.tsx web/src/pages/workspace/ui/sidebar-nav.tsx +48 −6 View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -2,6 +2,7 @@
22import { Link } from 'react-router-dom'
33import {
44 AtSign,
5 Bot,
56 ChevronDown,
67 ChevronRight,
78 File,
@@ -17,20 +18,27 @@
1718import { ApiFailure } from '@/shared/lib'
1819import { getVoiceSpaceStates, isVoiceRoom } from '@/entities/chat'
1920import { betweenPositions, STRIDE } from '../model/nav-order'
20import { prefetchBoardView, prefetchRepoView, prefetchRoomView } from '../model/prefetch'
21import type { BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
21import {
22 prefetchAgentConsole,
23 prefetchBoardView,
24 prefetchRepoView,
25 prefetchRoomView,
26} from '../model/prefetch'
27import type { AgentCollectionItem, BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
2228
2329export interface SidebarNavProps {
2430 spaceName: string
25 /** Space directory tree (folders + repo/board placeholders). */
31 /** Space directory tree (folders + repo/board/collection placeholders). */
2632 dirs: DirNode[]
2733 rooms: RoomBrief[]
2834 dms: DmItem[]
2935 repos: RepoItem[]
3036 boards: BoardItem[]
37 collections: AgentCollectionItem[]
3138 activeRoomId?: string
3239 activeBoardId?: string
3340 activeRepoName?: string
41 activeCollectionId?: string
3442 /** Both move endpoints require the space's `manage_channels` permission. */
3543 canOrganize?: boolean
3644 /** A move was persisted: the shell refetches the directory tree and rooms. */
@@ -38,7 +46,7 @@
3846}
3947
4048/** What a row is, after mapping the directory tree onto the space's assets. */
41type RowKind = 'folder' | 'repo' | 'board' | 'file' | 'room' | 'dm'
49type RowKind = 'folder' | 'repo' | 'board' | 'agent' | 'file' | 'room' | 'dm'
4250
4351/** One row of the view model, positioned inside its (effective) parent level. */
4452interface View {
@@ -55,7 +63,7 @@
5563 unread: number
5664 favorite: boolean
5765 voice: boolean
58 /** Board id behind a board row, so the active row can be matched exactly. */
66 /** Board (or collection) id behind a row, so the active one can be matched exactly. */
5967 assetId?: string
6068 /** Peer of a private-message row: its avatar replaces the channel icon. */
6169 peer?: { avatar_url?: string; display_name?: string; username?: string } | null
@@ -84,9 +92,11 @@
8492 dms,
8593 repos,
8694 boards,
95 collections,
8796 activeRoomId,
8897 activeBoardId,
8998 activeRepoName,
99 activeCollectionId,
90100 canOrganize = false,
91101 onChanged,
92102}: SidebarNavProps) {
@@ -157,6 +167,7 @@
157167 const dirIds = new Set(flat.map(({ node }) => node.id))
158168 const repoByName = new Map(repos.map(repo => [repo.name.toLowerCase(), repo]))
159169 const boardByDir = new Map(boards.map(board => [board.dir_id, board]))
170 const collectionByDir = new Map(collections.map(collection => [collection.dir_id, collection]))
160171 const visibleRooms = rooms.filter(
161172 room => room.kind !== 'Category' && room.kind !== 'Thread' && !room.hidden,
162173 )
@@ -170,6 +181,7 @@
170181
171182 const coveredRepoIds = new Set<string>()
172183 const coveredBoardIds = new Set<string>()
184 const coveredCollectionIds = new Set<string>()
173185 for (const { node, parent } of flat) {
174186 const key = `d:${node.id}`
175187 const edit = edits[key]
@@ -179,6 +191,7 @@
179191 let href: string | null = null
180192 let unread = 0
181193 const board = boardByDir.get(node.id)
194 const collection = collectionByDir.get(node.id)
182195 const repo = repoByName.get(node.name.toLowerCase())
183196 if (node.kind === 'Dir') kind = 'folder'
184197 else if (board) {
@@ -186,6 +199,11 @@
186199 href = `/app/${spacePath}/board/${encodeURIComponent(board.id)}`
187200 unread = board.open_issues_count
188201 coveredBoardIds.add(board.id)
202 } else if (collection) {
203 kind = 'agent'
204 href = `/app/${spacePath}/agent/${encodeURIComponent(collection.id)}`
205 unread = collection.running_tasks
206 coveredCollectionIds.add(collection.id)
189207 } else if (repo) {
190208 kind = 'repo'
191209 href = `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`
@@ -203,7 +221,7 @@
203221 unread,
204222 favorite: false,
205223 voice: false,
206 assetId: board?.id,
224 assetId: board?.id ?? collection?.id,
207225 })
208226 }
209227 for (const room of visibleRooms) {
@@ -276,6 +294,24 @@
276294 })),
277295 )
278296 appendTail(
297 collections
298 .filter(collection => !coveredCollectionIds.has(collection.id))
299 .map(collection => ({
300 key: `x:agent-${collection.id}`,
301 type: 'dir' as const,
302 id: '',
303 kind: 'agent' as RowKind,
304 label: collection.name,
305 href: `/app/${spacePath}/agent/${encodeURIComponent(collection.id)}`,
306 parentId: null,
307 position: 0,
308 unread: collection.running_tasks,
309 favorite: false,
310 voice: false,
311 assetId: collection.id,
312 })),
313 )
314 appendTail(
279315 dms.map(dm => ({
280316 key: `m:${dm.id}`,
281317 // No move endpoint exists for a DM, and `id` stays empty so `movable`
@@ -439,6 +475,8 @@
439475 void prefetchRepoView(spaceName, view.label)
440476 } else if (view.kind === 'board' && view.assetId) {
441477 prefetchBoardView(spaceName, view.assetId)
478 } else if (view.kind === 'agent' && view.assetId) {
479 prefetchAgentConsole(spaceName, view.assetId)
442480 }
443481 },
444482 [spaceName],
@@ -471,6 +509,9 @@
471509 active={
472510 (view.kind === 'room' && view.id === activeRoomId) ||
473511 (view.kind === 'board' && Boolean(activeBoardId) && view.assetId === activeBoardId) ||
512 (view.kind === 'agent' &&
513 Boolean(activeCollectionId) &&
514 view.assetId === activeCollectionId) ||
474515 (view.kind === 'repo' && view.label === activeRepoName)
475516 }
476517 dragging={dragKey === view.key}
@@ -549,6 +590,7 @@
549590 dm: <AtSign className="h-4 w-4" />,
550591 repo: <FolderGit2 className="h-4 w-4" />,
551592 board: <SquareKanban className="h-4 w-4" />,
593 agent: <Bot className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
552594 file: <File className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
553595}
554596
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +39 −13 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -2,6 +2,7 @@
22import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
33import { Loader2 } from 'lucide-react'
44import { BoardView } from './board/board-view'
5import { AgentView } from './agent/agent-view'
56import { ChatLayout } from './layout/chat-layout'
67import { ChatView } from './chat/chat-view'
78import { VoiceCallBar } from '@/features/chat'
@@ -35,7 +36,7 @@
3536 // notification points here); the param is consumed on open so a later
3637 // manual open starts from the default section again.
3738 const [searchParams, setSearchParams] = useSearchParams()
38 const { mode, spaceName, roomId, boardId, repoName } = route
39 const { mode, spaceName, roomId, boardId, repoName, collectionId } = route
3940 const exploring = mode === 'explore'
4041 const [creatingSpace, setCreatingSpace] = useState(false)
4142 const [spaceReload, setSpaceReload] = useState(0)
@@ -48,8 +49,8 @@
4849 const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined)
4950 const badgeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
5051 const {
51 me, spaces, rooms, dirs, dms, repos, boards,
52 room, dm, board,
52 me, spaces, rooms, dirs, dms, repos, boards, agents,
53 room, dm, board, agentCollection,
5354 } = useWorkspaceData(route, spaceReload, listsReload, meReload)
5455 const requestedSection = searchParams.get('settings')
5556 const consumeSectionParam = useCallback(() => {
@@ -89,11 +90,11 @@
8990 const idleWarmed = useRef<string | null>(null)
9091 useEffect(() => {
9192 if (exploring || spaceName === '@me' || idleWarmed.current === spaceName) return
92 if (rooms.loading || repos.loading || boards.loading) return
93 if (!rooms.data || !repos.data || !boards.data) return
93 if (rooms.loading || repos.loading || boards.loading || agents.loading) return
94 if (!rooms.data || !repos.data || !boards.data || !agents.data) return
9495 idleWarmed.current = spaceName
95 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data)
96 }, [exploring, spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data])
96 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data, agents.data)
97 }, [exploring, spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data, agents.loading, agents.data])
9798
9899 // A `?settings=<section>` link (join-request notifications) opens the
99100 // overlay for the space in scope. Without the permission that owns the
@@ -126,12 +127,14 @@
126127 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
127128 : mode === 'board' && boardId
128129 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
129 : roomId
130 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
131 : spaceName && spaceName !== '@me'
132 ? t('shell.titleSpace', { space: spaceName })
133 : base
134 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name])
130 : mode === 'agent' && collectionId
131 ? t('shell.titleAgent', { collection: agentCollection?.name || collectionId, space: spaceName })
132 : roomId
133 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
134 : spaceName && spaceName !== '@me'
135 ? t('shell.titleSpace', { space: spaceName })
136 : base
137 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name, collectionId, agentCollection?.name])
135138
136139 if (me.error instanceof ApiFailure && me.error.code === 401) {
137140 return <Navigate to="/auth/login" replace />
@@ -204,9 +207,11 @@
204207 dms={dms.data ?? []}
205208 repos={repos.data ?? []}
206209 boards={boards.data ?? []}
210 collections={agents.data ?? []}
207211 activeRoomId={roomId}
208212 activeBoardId={boardId}
209213 activeRepoName={repoName}
214 activeCollectionId={collectionId}
210215 canOrganize={canOrganize}
211216 onChanged={() => setListsReload(key => key + 1)}
212217 />
@@ -313,6 +318,27 @@
313318 )
314319 }
315320
321 if (mode === 'agent') {
322 return (
323 <>
324 <AgentView
325 spaceName={spaceName}
326 collectionId={collectionId ?? ''}
327 collectionHint={agentCollection}
328 shell={shell}
329 onCountsChanged={() => setListsReload(key => key + 1)}
330 />
331 {createSpaceModal}
332 {settingsModal}
333 {spaceSettingsModal}
334 <NotifyToaster />
335
336 <VoiceCallBar />
337 <GlobalSearchCommand scope={searchScope} />
338 </>
339 )
340 }
341
316342 if (mode === 'repo') {
317343 return (
318344 <>
Added web/src/shared/i18n/messages/en/agent.ts web/src/shared/i18n/messages/en/agent.ts +109 −0 View web/src/shared/i18n/messages/en/agent.ts
@@ -0,0 +1,109 @@
1/** Sandbox-agent console (agent collections / agent tasks). */
2export const agent = {
3 'agent.console.title': 'Agents',
4 'agent.console.pick': 'Pick an agent collection',
5 'agent.console.notFound': 'This agent collection does not exist',
6 'agent.console.forbidden': 'You cannot view this agent collection',
7 'agent.console.loadFailed': 'Failed to load the agent collection',
8 'agent.console.deletedHint': 'It may have been deleted or moved elsewhere.',
9 'agent.console.return': 'Back to the space',
10 'agent.console.empty': 'No tasks yet',
11 'agent.console.emptyNote': 'Create a task and hand it to the sandbox agent.',
12 'agent.console.emptyFiltered': 'No tasks match these filters',
13 'agent.console.new': 'New task',
14 'agent.console.manage': 'Manage collection',
15 'agent.console.viewCards': 'Cards',
16 'agent.console.viewList': 'List',
17 'agent.console.filter.state': 'State',
18 'agent.console.filter.priority': 'Priority',
19 'agent.console.filter.sort': 'Sort',
20 'agent.console.filter.search': 'Search tasks',
21 'agent.console.filter.reset': 'Reset filters',
22 'agent.console.errorList': 'task list',
23 'agent.console.errorTask': 'task detail',
24 'agent.console.pageOutOfRange': 'Page {page} is out of range: {total} tasks, {limit} per page.',
25 'agent.console.backToFirst': 'Back to page 1',
26 'agent.console.prev': 'Previous',
27 'agent.console.next': 'Next',
28 'agent.console.pager': 'Page {page} / {pages} · {total} tasks',
29 'agent.console.task.number': 'No.',
30 'agent.console.task.title': 'Title',
31 'agent.console.task.state': 'State',
32 'agent.console.task.priority': 'Priority',
33 'agent.console.task.createdAt': 'Created',
34 'agent.console.task.actions': 'Actions',
35 'agent.console.task.start': 'Start',
36 'agent.console.task.cancel': 'Cancel run',
37 'agent.console.task.close': 'Close task',
38 'agent.console.task.reopen': 'Reopen task',
39 'agent.console.task.delete': 'Delete task',
40 'agent.console.task.deleteConfirm': 'The task and its conversation and activity records are deleted with it, and this cannot be undone.',
41 'agent.console.task.edit': 'Edit task',
42 'agent.console.task.watch': 'Watch task',
43 'agent.console.task.back': 'Back to list',
44 'agent.console.task.body': 'Task brief',
45 'agent.console.task.bodyFrozen': 'The task is running; its brief is frozen.',
46 'agent.console.task.noBody': 'No task brief.',
47 'agent.console.task.conversation': 'Conversation',
48 'agent.console.task.noMessages': 'No messages yet.',
49 'agent.console.task.activity': 'Activity',
50 'agent.console.task.noEvents': 'No activity yet.',
51 'agent.console.task.composerPlaceholder': 'Send the agent a message…',
52 'agent.console.task.send': 'Send',
53 'agent.console.task.startHint': 'This task has not started; use “Start” to hand the brief to the agent.',
54 'agent.console.form.createTitle': 'New task',
55 'agent.console.form.editTitle': 'Edit task',
56 'agent.console.form.title': 'Title',
57 'agent.console.form.titlePlaceholder': 'One line on what this task should do',
58 'agent.console.form.titleRequired': 'Title is required',
59 'agent.console.form.description': 'Summary',
60 'agent.console.form.body': 'Task brief',
61 'agent.console.form.priority': 'Priority',
62 'agent.console.form.dueAt': 'Due date',
63 'agent.console.manage.deleteConfirm': 'Its tasks are deleted with it, and this cannot be undone.',
64 'agent.console.manage.stats': '{running} running · {total} tasks in total',
65 'agent.console.actionFailed': 'The action failed',
66 'agent.console.streamLost': 'Live connection closed',
67 'agent.console.reconnecting': 'Live connection dropped, reconnecting…',
68 'agent.state.draft': 'Draft',
69 'agent.state.pending': 'Queued',
70 'agent.state.planning': 'Planning',
71 'agent.state.executing': 'Executing',
72 'agent.state.waiting': 'Waiting',
73 'agent.state.succeeded': 'Succeeded',
74 'agent.state.failed': 'Failed',
75 'agent.state.canceled': 'Canceled',
76 'agent.state.closed': 'Closed',
77 'agent.state.all': 'All',
78 'agent.sort.created': 'Newest',
79 'agent.sort.updated': 'Recently updated',
80 'agent.sort.priority': 'Priority',
81 'agent.sort.number': 'Number',
82 'agent.role.user': 'User',
83 'agent.role.agent': 'Agent',
84 'agent.role.system': 'System',
85 'agent.role.tool': 'Tool',
86 'agent.watch.watching': 'Watching',
87 'agent.watch.unwatched': 'Not watching',
88 'agent.watch.ignore': 'Ignoring',
89 'agent.event.created': 'created the task',
90 'agent.event.title_updated': 'changed the title',
91 'agent.event.body_updated': 'changed the task brief',
92 'agent.event.started': 'started the run',
93 'agent.event.state_changed': 'state changed',
94 'agent.event.message_added': 'new message',
95 'agent.event.run_event': 'run event',
96 'agent.event.run_failed': 'run failed',
97 'agent.event.artifact_added': 'produced an artifact',
98 'agent.event.subscriber_lost': 'live subscription dropped',
99 'agent.event.closed': 'closed the task',
100 'agent.event.reopened': 'reopened the task',
101 'agent.event.unknown': 'Unknown activity',
102 'agent.event.fallback': 'Activity: {type}',
103 'space.menu.createAgent': 'New agent collection',
104 'space.agent.create.title': 'Create agent collection',
105 'space.agent.nameField': 'Name',
106 'space.agent.description': 'Description',
107 'space.agent.icon': 'Icon',
108 'shell.titleAgent': '{collection} · {space} · GitDataAI',
109}
No newline at end of file
Modified web/src/shared/i18n/messages/en/index.ts web/src/shared/i18n/messages/en/index.ts +2 −0 View web/src/shared/i18n/messages/en/index.ts
@@ -1,4 +1,5 @@
11import type { MessageKey } from '../zh-CN'
2import { agent } from './agent'
23import { auth } from './auth'
34import { chat } from './chat'
45import { chatComposer } from './chatComposer'
@@ -23,6 +24,7 @@
2324 * compile error rather than a silent runtime fallback.
2425 */
2526export const en: Record<MessageKey, string> = {
27 ...agent,
2628 ...common,
2729 ...auth,
2830 ...errors,
Added web/src/shared/i18n/messages/zh-CN/agent.ts web/src/shared/i18n/messages/zh-CN/agent.ts +109 −0 View web/src/shared/i18n/messages/zh-CN/agent.ts
@@ -0,0 +1,109 @@
1/** Sandbox-agent console (智能体集 / 智能体任务). */
2export const agent = {
3 'agent.console.title': '智能体',
4 'agent.console.pick': '选择一个智能体集',
5 'agent.console.notFound': '这个智能体集不存在',
6 'agent.console.forbidden': '你没有权限查看这个智能体集',
7 'agent.console.loadFailed': '智能体集加载失败',
8 'agent.console.deletedHint': '它可能已被删除,或改到了别处。',
9 'agent.console.return': '返回空间首页',
10 'agent.console.empty': '还没有任务',
11 'agent.console.emptyNote': '创建一个任务,交给沙箱智能体执行。',
12 'agent.console.emptyFiltered': '没有符合条件的任务',
13 'agent.console.new': '新建任务',
14 'agent.console.manage': '管理智能体集',
15 'agent.console.viewCards': '卡片',
16 'agent.console.viewList': '列表',
17 'agent.console.filter.state': '状态',
18 'agent.console.filter.priority': '优先级',
19 'agent.console.filter.sort': '排序',
20 'agent.console.filter.search': '搜索任务',
21 'agent.console.filter.reset': '重置筛选',
22 'agent.console.errorList': '任务列表',
23 'agent.console.errorTask': '任务详情',
24 'agent.console.pageOutOfRange': '第 {page} 页超出范围:共 {total} 个任务,每页 {limit} 条。',
25 'agent.console.backToFirst': '回到第 1 页',
26 'agent.console.prev': '上一页',
27 'agent.console.next': '下一页',
28 'agent.console.pager': '第 {page} / {pages} 页 · 共 {total} 个任务',
29 'agent.console.task.number': '编号',
30 'agent.console.task.title': '标题',
31 'agent.console.task.state': '状态',
32 'agent.console.task.priority': '优先级',
33 'agent.console.task.createdAt': '创建时间',
34 'agent.console.task.actions': '操作',
35 'agent.console.task.start': '开始执行',
36 'agent.console.task.cancel': '取消执行',
37 'agent.console.task.close': '关闭任务',
38 'agent.console.task.reopen': '重新打开',
39 'agent.console.task.delete': '删除任务',
40 'agent.console.task.deleteConfirm': '任务及其对话、活动记录会被一起删除,且不可恢复。',
41 'agent.console.task.edit': '编辑任务',
42 'agent.console.task.watch': '关注任务',
43 'agent.console.task.back': '返回列表',
44 'agent.console.task.body': '任务描述',
45 'agent.console.task.bodyFrozen': '任务已开始执行,描述不再允许修改。',
46 'agent.console.task.noBody': '没有填写任务描述。',
47 'agent.console.task.conversation': '对话',
48 'agent.console.task.noMessages': '还没有消息。',
49 'agent.console.task.activity': '活动记录',
50 'agent.console.task.noEvents': '还没有活动记录。',
51 'agent.console.task.composerPlaceholder': '给智能体发一条消息…',
52 'agent.console.task.send': '发送',
53 'agent.console.task.startHint': '任务还没有开始;点「开始执行」把任务描述发给智能体。',
54 'agent.console.form.createTitle': '新建任务',
55 'agent.console.form.editTitle': '编辑任务',
56 'agent.console.form.title': '标题',
57 'agent.console.form.titlePlaceholder': '一句话说明这个任务要做什么',
58 'agent.console.form.titleRequired': '请填写标题',
59 'agent.console.form.description': '简述',
60 'agent.console.form.body': '任务描述',
61 'agent.console.form.priority': '优先级',
62 'agent.console.form.dueAt': '截止日期',
63 'agent.console.manage.deleteConfirm': '集合里的任务会一起删除,且不可恢复。',
64 'agent.console.manage.stats': '执行中 {running} 个 · 共 {total} 个任务',
65 'agent.console.actionFailed': '操作失败',
66 'agent.console.streamLost': '实时连接已断开',
67 'agent.console.reconnecting': '实时连接中断,正在重连…',
68 'agent.state.draft': '草稿',
69 'agent.state.pending': '排队中',
70 'agent.state.planning': '规划中',
71 'agent.state.executing': '执行中',
72 'agent.state.waiting': '等待中',
73 'agent.state.succeeded': '已完成',
74 'agent.state.failed': '已失败',
75 'agent.state.canceled': '已取消',
76 'agent.state.closed': '已关闭',
77 'agent.state.all': '全部',
78 'agent.sort.created': '最新创建',
79 'agent.sort.updated': '最近更新',
80 'agent.sort.priority': '优先级',
81 'agent.sort.number': '编号',
82 'agent.role.user': '用户',
83 'agent.role.agent': '智能体',
84 'agent.role.system': '系统',
85 'agent.role.tool': '工具',
86 'agent.watch.watching': '已关注',
87 'agent.watch.unwatched': '未关注',
88 'agent.watch.ignore': '已忽略',
89 'agent.event.created': '创建了任务',
90 'agent.event.title_updated': '修改了标题',
91 'agent.event.body_updated': '修改了任务描述',
92 'agent.event.started': '开始执行',
93 'agent.event.state_changed': '状态变化',
94 'agent.event.message_added': '收到新消息',
95 'agent.event.run_event': '执行事件',
96 'agent.event.run_failed': '执行失败',
97 'agent.event.artifact_added': '产出了新文件',
98 'agent.event.subscriber_lost': '实时订阅中断',
99 'agent.event.closed': '关闭了任务',
100 'agent.event.reopened': '重新打开了任务',
101 'agent.event.unknown': '未知活动',
102 'agent.event.fallback': '活动:{type}',
103 'space.menu.createAgent': '新建智能体集',
104 'space.agent.create.title': '创建智能体集',
105 'space.agent.nameField': '名称',
106 'space.agent.description': '描述',
107 'space.agent.icon': '图标',
108 'shell.titleAgent': '{collection} · {space} · GitDataAI',
109}
No newline at end of file
Modified web/src/shared/i18n/messages/zh-CN/index.ts web/src/shared/i18n/messages/zh-CN/index.ts +2 −0 View web/src/shared/i18n/messages/zh-CN/index.ts
@@ -1,3 +1,4 @@
1import { agent } from './agent'
12import { auth } from './auth'
23import { chat } from './chat'
34import { chatComposer } from './chatComposer'
@@ -23,6 +24,7 @@
2324 * checked against it, so drift between the two fails `tsc`.
2425 */
2526export const zhCN = {
27 ...agent,
2628 ...common,
2729 ...auth,
2830 ...errors,
Modified web/src/shared/lib/cache-keys.ts web/src/shared/lib/cache-keys.ts +9 −0 View web/src/shared/lib/cache-keys.ts
@@ -83,6 +83,15 @@
8383export const issueKey = (space: string, board: string, issueId: string, part: string) =>
8484 `issue:${space}/${board}/${issueId}:${part}`
8585
86/* -------------------------------------------------------------------- agents */
87
88/** Agent collections of one space (the sidebar / picker list). */
89export const spaceAgentsKey = (space: string) => `space:${space}:agents`
90
91/** Agent-console payloads: `collection`, `tasks:{queryKey}`, `messages`, `events`. */
92export const agentKey = (space: string, collection: string, part: string) =>
93 `agent:${space}/${collection}:${part}`
94
8695/* -------------------------------------------------------------------- repos */
8796
8897/**
Modified web/steiger.config.js web/steiger.config.js +1 −0 View web/steiger.config.js
@@ -12,6 +12,7 @@
1212// 本清单应随架构演进同步修剪(切片被第二个消费者使用,或删除后记得移除)。
1313const INSIGNIFICANT_SLICE_ALLOWLIST = new Set([
1414 'entities/search',
15 'features/agent-console',
1516 'features/chat',
1617 'features/issue-board',
1718 'features/notifications',