mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 05:55:28 +08:00
feat(agents): add manual drag-and-drop sorting for agent and session lists (#13460)
### What this PR does
Before this PR:
Agents and sessions in the sidebar are listed in fixed order with no way
to manually reorder them.
After this PR:
Both agents and sessions can be reordered via drag-and-drop. The order
is persisted to the SQLite database via a new `sort_order` column, and
new items are automatically placed at the top of the list. Session
sorting is scoped per-agent (each agent's sessions are independently
sortable).
### Why we need it and why it was done in this way
The following tradeoffs were made:
- Used `sort_order` integer column in SQLite (backend persistence)
rather than Redux/localStorage, because agents/sessions data lives in
the backend DB — consistent with the existing architecture.
- New agents/sessions get `sort_order = 0` and all existing items are
shifted up by 1, matching the assistant list behavior where new items
appear at the top.
- Reused `DraggableList` component for agents and `DraggableVirtualList`
for sessions (both using `@hello-pangea/dnd`) for consistency with
existing drag-and-drop patterns.
- Added dedicated `PUT /v1/agents/reorder` and `PUT
/v1/agents/{agentId}/sessions/reorder` endpoints rather than updating
sort_order through individual PATCH calls, to ensure atomic reorder
operations.
The following alternatives were considered:
- Storing order in Redux/localStorage — rejected because agents/sessions
are fetched from the backend SQLite DB via HTTP API, so the order would
diverge.
- Using `@dnd-kit/sortable` instead of `@hello-pangea/dnd` — rejected to
maintain consistency with existing components.
### Breaking changes
None. The new `sort_order` column defaults to `0` for all existing
agents and sessions, so they will share the same sort order initially
and fall back to `created_at DESC` as tie-breaker.
### Special notes for your reviewer
**Database migration:**
- `0003_slippery_wild_pack.sql` adds `sort_order INTEGER NOT NULL
DEFAULT 0` to both `agents` and `sessions` tables
- Schema defines indexes (`idx_agents_sort_order`,
`idx_sessions_sort_order`) for query performance
- Existing rows get `sort_order = 0` by default; the first reorder
operation will assign proper values
**API changes:**
- `PUT /v1/agents/reorder` — accepts `{ ordered_ids: string[] }` to
reorder agents
- `PUT /v1/agents/{agentId}/sessions/reorder` — accepts `{ ordered_ids:
string[] }` to reorder sessions (scoped to agent)
- `GET /v1/agents` — default sort changed to `sort_order ASC, created_at
DESC`
- `GET /v1/agents/{agentId}/sessions` — default sort changed to
`sort_order ASC, created_at DESC`
**Frontend:**
- `useAgents` hook exposes `reorderAgents()` with optimistic SWR cache
update
- `useSessions` hook exposes `reorderSessions()` with optimistic
SWRInfinite cache update (preserves real total for pagination)
- `AgentSidePanel` uses `DraggableList` for agent drag-and-drop
- `Sessions` component swapped from `DynamicVirtualList` to
`DraggableVirtualList` for session drag-and-drop
**Atomicity:**
- `createSession` wraps sort_order shift + insert in a transaction
- `reorderAgents` and `reorderSessions` both use transactions
### Checklist
- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A user-guide update was considered and is present
(link) or not required. Check this only when the PR introduces or
changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code before requesting review
from others
### Release note
```release-note
Added drag-and-drop manual sorting for agent and session lists in the sidebar panel.
```
---------
Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
2
resources/database/drizzle/0003_slippery_wild_pack.sql
Normal file
2
resources/database/drizzle/0003_slippery_wild_pack.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `agents` ADD `sort_order` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `sort_order` integer DEFAULT 0 NOT NULL;
|
||||
362
resources/database/drizzle/meta/0003_snapshot.json
Normal file
362
resources/database/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "2bde7356-6a69-4445-b163-3299b4b4972f",
|
||||
"prevId": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session_messages": {
|
||||
"name": "session_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"executed_at": {
|
||||
"name": "executed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slash_commands": {
|
||||
"name": "slash_commands",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1762526423527,
|
||||
"tag": "0002_wealthy_naoko",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1773483572773,
|
||||
"tag": "0003_slippery_wild_pack",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -718,8 +718,8 @@
|
||||
"name": "sortBy",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["created_at", "updated_at", "name"],
|
||||
"default": "created_at"
|
||||
"enum": ["created_at", "updated_at", "name", "sort_order"],
|
||||
"default": "sort_order"
|
||||
},
|
||||
"description": "Field to sort by"
|
||||
},
|
||||
@@ -731,7 +731,7 @@
|
||||
"enum": ["asc", "desc"],
|
||||
"default": "desc"
|
||||
},
|
||||
"description": "Sort order (asc = ascending, desc = descending)"
|
||||
"description": "Sort order (asc = ascending, desc = descending). Defaults to asc when sortBy is sort_order."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1087,6 +1087,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/agents/reorder": {
|
||||
"put": {
|
||||
"summary": "Reorder agents",
|
||||
"description": "Sets the display order of agents based on the provided array of agent IDs",
|
||||
"tags": ["Agents"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ordered_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Array of agent IDs in the desired display order"
|
||||
}
|
||||
},
|
||||
"required": ["ordered_ids"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Agents reordered successfully"
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agents": {
|
||||
"post": {
|
||||
"summary": "Create a new agent",
|
||||
@@ -1482,6 +1534,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agents/{agentId}/sessions/reorder": {
|
||||
"put": {
|
||||
"summary": "Reorder sessions for an agent",
|
||||
"tags": ["Sessions"],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "agentId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Agent ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ordered_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Array of session IDs in desired order"
|
||||
}
|
||||
},
|
||||
"required": ["ordered_ids"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sessions reordered successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agents/{agentId}/sessions/{sessionId}": {
|
||||
"get": {
|
||||
"summary": "Get session by ID",
|
||||
|
||||
@@ -137,8 +137,8 @@ export const createAgent = async (req: Request, res: Response): Promise<Response
|
||||
* name: sortBy
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, updated_at, name]
|
||||
* default: created_at
|
||||
* enum: [created_at, updated_at, name, sort_order]
|
||||
* default: sort_order
|
||||
* description: Field to sort by
|
||||
* - in: query
|
||||
* name: orderBy
|
||||
@@ -146,7 +146,7 @@ export const createAgent = async (req: Request, res: Response): Promise<Response
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: Sort order (asc = ascending, desc = descending)
|
||||
* description: Sort order (asc = ascending, desc = descending). Defaults to asc when sortBy is sort_order.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of agents
|
||||
@@ -185,8 +185,8 @@ export const listAgents = async (req: Request, res: Response): Promise<Response>
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||
const sortBy = (req.query.sortBy as 'created_at' | 'updated_at' | 'name') || 'created_at'
|
||||
const orderBy = (req.query.orderBy as 'asc' | 'desc') || 'desc'
|
||||
const sortBy = (req.query.sortBy as 'created_at' | 'updated_at' | 'name' | 'sort_order') || 'sort_order'
|
||||
const orderBy = (req.query.orderBy as 'asc' | 'desc') || (sortBy === 'sort_order' ? 'asc' : 'desc')
|
||||
|
||||
logger.debug('Listing agents', { limit, offset, sortBy, orderBy })
|
||||
|
||||
@@ -582,3 +582,75 @@ export const deleteAgent = async (req: Request, res: Response): Promise<Response
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents/reorder:
|
||||
* put:
|
||||
* summary: Reorder agents
|
||||
* description: Sets the display order of agents based on the provided array of agent IDs
|
||||
* tags: [Agents]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ordered_ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of agent IDs in the desired display order
|
||||
* required:
|
||||
* - ordered_ids
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agents reordered successfully
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const reorderAgents = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { ordered_ids } = req.body
|
||||
|
||||
if (
|
||||
!Array.isArray(ordered_ids) ||
|
||||
ordered_ids.length === 0 ||
|
||||
!ordered_ids.every((id: unknown) => typeof id === 'string' && id.length > 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'ordered_ids must be a non-empty array of agent IDs',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_ordered_ids'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug('Reordering agents', { count: ordered_ids.length })
|
||||
await agentService.reorderAgents(ordered_ids)
|
||||
|
||||
logger.info('Agents reordered', { count: ordered_ids.length })
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error('Error reordering agents', { error })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to reorder agents',
|
||||
type: 'internal_error',
|
||||
code: 'agent_reorder_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,42 @@ export const deleteSession = async (req: Request, res: Response): Promise<Respon
|
||||
}
|
||||
}
|
||||
|
||||
export const reorderSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId } = req.params
|
||||
try {
|
||||
const { ordered_ids } = req.body
|
||||
|
||||
if (
|
||||
!Array.isArray(ordered_ids) ||
|
||||
ordered_ids.length === 0 ||
|
||||
!ordered_ids.every((id: unknown) => typeof id === 'string' && id.length > 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'ordered_ids must be a non-empty array of session IDs',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_ordered_ids'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug('Reordering sessions', { agentId, count: ordered_ids.length })
|
||||
await sessionService.reorderSessions(agentId, ordered_ids)
|
||||
|
||||
logger.info('Sessions reordered', { agentId, count: ordered_ids.length })
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error('Error reordering sessions', { error, agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to reorder sessions',
|
||||
type: 'internal_error',
|
||||
code: 'session_reorder_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience endpoints for sessions without agent context
|
||||
export const listAllSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
|
||||
@@ -387,6 +387,9 @@ const agentsRouter = express.Router()
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// Reorder route — must be before /:agentId to avoid treating "reorder" as an agentId
|
||||
agentsRouter.put('/reorder', agentHandlers.reorderAgents)
|
||||
|
||||
// Agent CRUD routes
|
||||
agentsRouter.post('/', validateAgent, handleValidationErrors, agentHandlers.createAgent)
|
||||
|
||||
@@ -605,6 +608,53 @@ const createSessionsRouter = (): express.Router => {
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// Reorder route — must be before /:sessionId to avoid treating "reorder" as a sessionId
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/reorder:
|
||||
* put:
|
||||
* summary: Reorder sessions for an agent
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ordered_ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of session IDs in desired order
|
||||
* required:
|
||||
* - ordered_ids
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Sessions reordered successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.put('/reorder', sessionHandlers.reorderSessions)
|
||||
|
||||
sessionsRouter.post('/', validateSession, handleValidationErrors, sessionHandlers.createSession)
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Drizzle ORM schema for agents table
|
||||
*/
|
||||
|
||||
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const agentsTable = sqliteTable('agents', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -22,6 +22,8 @@ export const agentsTable = sqliteTable('agents', {
|
||||
|
||||
configuration: text('configuration'), // JSON, extensible settings
|
||||
|
||||
sort_order: integer('sort_order').notNull().default(0), // Manual sort order (lower = first)
|
||||
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
})
|
||||
@@ -30,6 +32,7 @@ export const agentsTable = sqliteTable('agents', {
|
||||
export const agentsNameIdx = index('idx_agents_name').on(agentsTable.name)
|
||||
export const agentsTypeIdx = index('idx_agents_type').on(agentsTable.type)
|
||||
export const agentsCreatedAtIdx = index('idx_agents_created_at').on(agentsTable.created_at)
|
||||
export const agentsSortOrderIdx = index('idx_agents_sort_order').on(agentsTable.sort_order)
|
||||
|
||||
export type AgentRow = typeof agentsTable.$inferSelect
|
||||
export type InsertAgentRow = typeof agentsTable.$inferInsert
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Drizzle ORM schema for sessions and session_logs tables
|
||||
*/
|
||||
|
||||
import { foreignKey, index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
import { agentsTable } from './agents.schema'
|
||||
|
||||
@@ -26,6 +26,8 @@ export const sessionsTable = sqliteTable('sessions', {
|
||||
|
||||
configuration: text('configuration'), // JSON, extensible settings
|
||||
|
||||
sort_order: integer('sort_order').notNull().default(0), // Manual sort order (lower = first)
|
||||
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
})
|
||||
@@ -41,6 +43,7 @@ export const sessionsFkAgent = foreignKey({
|
||||
export const sessionsCreatedAtIdx = index('idx_sessions_created_at').on(sessionsTable.created_at)
|
||||
export const sessionsMainAgentIdIdx = index('idx_sessions_agent_id').on(sessionsTable.agent_id)
|
||||
export const sessionsModelIdx = index('idx_sessions_model').on(sessionsTable.model)
|
||||
export const sessionsSortOrderIdx = index('idx_sessions_sort_order').on(sessionsTable.sort_order)
|
||||
|
||||
export type SessionRow = typeof sessionsTable.$inferSelect
|
||||
export type InsertSessionRow = typeof sessionsTable.$inferInsert
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
UpdateAgentResponse
|
||||
} from '@types'
|
||||
import { AgentBaseSchema } from '@types'
|
||||
import { asc, count, desc, eq } from 'drizzle-orm'
|
||||
import { asc, count, desc, eq, sql } from 'drizzle-orm'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
|
||||
@@ -55,12 +55,17 @@ export class AgentService extends BaseService {
|
||||
small_model: req.small_model,
|
||||
configuration: serializedReq.configuration,
|
||||
accessible_paths: serializedReq.accessible_paths,
|
||||
sort_order: 0,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const database = await this.getDatabase()
|
||||
await database.insert(agentsTable).values(insertData)
|
||||
// Shift all existing agents' sort_order up by 1 and insert new agent at position 0 atomically
|
||||
await database.transaction(async (tx) => {
|
||||
await tx.update(agentsTable).set({ sort_order: sql`${agentsTable.sort_order} + 1` })
|
||||
await tx.insert(agentsTable).values(insertData)
|
||||
})
|
||||
const result = await database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
|
||||
if (!result[0]) {
|
||||
throw new Error('Failed to create agent')
|
||||
@@ -108,13 +113,17 @@ export class AgentService extends BaseService {
|
||||
const database = await this.getDatabase()
|
||||
const totalResult = await database.select({ count: count() }).from(agentsTable)
|
||||
|
||||
const sortBy = options.sortBy || 'created_at'
|
||||
const orderBy = options.orderBy || 'desc'
|
||||
const sortBy = options.sortBy || 'sort_order'
|
||||
const orderBy = options.orderBy || (sortBy === 'sort_order' ? 'asc' : 'desc')
|
||||
|
||||
const sortField = agentsTable[sortBy]
|
||||
const orderFn = orderBy === 'asc' ? asc : desc
|
||||
|
||||
const baseQuery = database.select().from(agentsTable).orderBy(orderFn(sortField))
|
||||
// Use created_at DESC as secondary sort for tie-breaking (e.g., after migration when all sort_order = 0)
|
||||
const baseQuery =
|
||||
sortBy === 'sort_order'
|
||||
? database.select().from(agentsTable).orderBy(orderFn(sortField), desc(agentsTable.created_at))
|
||||
: database.select().from(agentsTable).orderBy(orderFn(sortField))
|
||||
|
||||
const result =
|
||||
options.limit !== undefined
|
||||
@@ -186,6 +195,16 @@ export class AgentService extends BaseService {
|
||||
return await this.getAgent(id)
|
||||
}
|
||||
|
||||
async reorderAgents(orderedIds: string[]): Promise<void> {
|
||||
const database = await this.getDatabase()
|
||||
await database.transaction(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx.update(agentsTable).set({ sort_order: i }).where(eq(agentsTable.id, orderedIds[i]))
|
||||
}
|
||||
})
|
||||
logger.info('Agents reordered', { count: orderedIds.length })
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<boolean> {
|
||||
const database = await this.getDatabase()
|
||||
const result = await database.delete(agentsTable).where(eq(agentsTable.id, id))
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ListOptions,
|
||||
type UpdateSessionRequest
|
||||
} from '@types'
|
||||
import { and, count, desc, eq, type SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, type SQL, sql } from 'drizzle-orm'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
|
||||
@@ -126,12 +126,20 @@ export class SessionService extends BaseService {
|
||||
mcps: serializedData.mcps || null,
|
||||
allowed_tools: serializedData.allowed_tools || null,
|
||||
configuration: serializedData.configuration || null,
|
||||
sort_order: 0,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const db = await this.getDatabase()
|
||||
await db.insert(sessionsTable).values(insertData)
|
||||
// Shift all existing sessions' sort_order up by 1 and insert new session at position 0 atomically
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(sessionsTable)
|
||||
.set({ sort_order: sql`${sessionsTable.sort_order} + 1` })
|
||||
.where(eq(sessionsTable.agent_id, agentId))
|
||||
await tx.insert(sessionsTable).values(insertData)
|
||||
})
|
||||
|
||||
const result = await db.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1)
|
||||
|
||||
@@ -192,8 +200,12 @@ export class SessionService extends BaseService {
|
||||
|
||||
const total = totalResult[0].count
|
||||
|
||||
// Build list query with pagination - sort by updated_at descending (latest first)
|
||||
const baseQuery = database.select().from(sessionsTable).where(whereClause).orderBy(desc(sessionsTable.updated_at))
|
||||
// Build list query with pagination - sort by sort_order ASC, created_at DESC for tie-breaking
|
||||
const baseQuery = database
|
||||
.select()
|
||||
.from(sessionsTable)
|
||||
.where(whereClause)
|
||||
.orderBy(asc(sessionsTable.sort_order), desc(sessionsTable.created_at))
|
||||
|
||||
const result =
|
||||
options.limit !== undefined
|
||||
@@ -273,6 +285,19 @@ export class SessionService extends BaseService {
|
||||
return result.rowsAffected > 0
|
||||
}
|
||||
|
||||
async reorderSessions(agentId: string, orderedIds: string[]): Promise<void> {
|
||||
const database = await this.getDatabase()
|
||||
await database.transaction(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx
|
||||
.update(sessionsTable)
|
||||
.set({ sort_order: i })
|
||||
.where(and(eq(sessionsTable.id, orderedIds[i]), eq(sessionsTable.agent_id, agentId)))
|
||||
}
|
||||
})
|
||||
logger.info('Sessions reordered', { agentId, count: orderedIds.length })
|
||||
}
|
||||
|
||||
async sessionExists(agentId: string, id: string): Promise<boolean> {
|
||||
const database = await this.getDatabase()
|
||||
const result = await database
|
||||
|
||||
@@ -99,6 +99,15 @@ export class AgentApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async reorderAgents(orderedIds: string[]): Promise<void> {
|
||||
const url = `${this.agentPaths.base}/reorder`
|
||||
try {
|
||||
await this.axios.put(url, { ordered_ids: orderedIds })
|
||||
} catch (error) {
|
||||
throw processError(error, 'Failed to reorder agents.')
|
||||
}
|
||||
}
|
||||
|
||||
public async listAgents(options?: ListOptions): Promise<ListAgentsResponse> {
|
||||
const url = this.agentPaths.base
|
||||
try {
|
||||
@@ -172,6 +181,15 @@ export class AgentApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async reorderSessions(agentId: string, orderedIds: string[]): Promise<void> {
|
||||
const url = `${this.getSessionPaths(agentId).base}/reorder`
|
||||
try {
|
||||
await this.axios.put(url, { ordered_ids: orderedIds })
|
||||
} catch (error) {
|
||||
throw processError(error, 'Failed to reorder sessions.')
|
||||
}
|
||||
}
|
||||
|
||||
public async listSessions(agentId: string, options?: ListOptions): Promise<ListAgentSessionsResponse> {
|
||||
const url = this.getSessionPaths(agentId).base
|
||||
try {
|
||||
|
||||
@@ -129,7 +129,6 @@ function DraggableVirtualList<T>({
|
||||
className={`${className} draggable-virtual-list`}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
{header}
|
||||
<Droppable
|
||||
droppableId="droppable"
|
||||
mode="virtual"
|
||||
@@ -168,6 +167,7 @@ function DraggableVirtualList<T>({
|
||||
overflowY: 'auto',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{header}
|
||||
<div
|
||||
className="virtual-list"
|
||||
style={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setActiveAgentId, setActiveSessionIdAction } from '@renderer/store/runtime'
|
||||
import type { AddAgentForm, CreateAgentResponse } from '@renderer/types'
|
||||
import type { AddAgentForm, CreateAgentResponse, GetAgentResponse } from '@renderer/types'
|
||||
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -37,7 +37,7 @@ export const useAgents = () => {
|
||||
if (!apiServerRunning) {
|
||||
throw new Error(t('agent.server.error.not_running'))
|
||||
}
|
||||
const result = await client.listAgents({ sortBy: 'created_at', orderBy: 'desc' })
|
||||
const result = await client.listAgents({ sortBy: 'sort_order', orderBy: 'asc' })
|
||||
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
|
||||
return result.data
|
||||
}, [apiServerConfig.enabled, apiServerRunning, client, t])
|
||||
@@ -51,7 +51,7 @@ export const useAgents = () => {
|
||||
async (form: AddAgentForm): Promise<Result<CreateAgentResponse>> => {
|
||||
try {
|
||||
const result = await client.createAgent(form)
|
||||
mutate((prev) => [...(prev ?? []), result])
|
||||
mutate((prev) => [result, ...(prev ?? [])])
|
||||
window.toast.success(t('common.add_success'))
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
@@ -97,12 +97,28 @@ export const useAgents = () => {
|
||||
[client, mutate]
|
||||
)
|
||||
|
||||
const reorderAgents = useCallback(
|
||||
async (reorderedList: GetAgentResponse[]) => {
|
||||
const orderedIds = reorderedList.map((a) => a.id)
|
||||
// Optimistic update
|
||||
mutate(reorderedList, false)
|
||||
try {
|
||||
await client.reorderAgents(orderedIds)
|
||||
} catch (error) {
|
||||
mutate()
|
||||
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.reorder.error.failed')))
|
||||
}
|
||||
},
|
||||
[client, mutate, t]
|
||||
)
|
||||
|
||||
return {
|
||||
agents: data,
|
||||
error,
|
||||
isLoading,
|
||||
addAgent,
|
||||
deleteAgent,
|
||||
getAgent
|
||||
getAgent,
|
||||
reorderAgents
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentSessionEntity,
|
||||
CreateAgentSessionResponse,
|
||||
CreateSessionForm,
|
||||
GetAgentSessionResponse,
|
||||
@@ -131,6 +132,28 @@ export const useSessions = (agentId: string | null, pageSize = DEFAULT_PAGE_SIZE
|
||||
[agentId, client, mutate, t]
|
||||
)
|
||||
|
||||
const reorderSessions = useCallback(
|
||||
async (reorderedList: AgentSessionEntity[]) => {
|
||||
if (!agentId) return
|
||||
const orderedIds = reorderedList.map((s) => s.id)
|
||||
// Optimistic update: replace all pages with single page containing reordered list
|
||||
mutate(
|
||||
(prev) => {
|
||||
const realTotal = prev && prev.length > 0 ? prev[prev.length - 1].total : reorderedList.length
|
||||
return [{ data: reorderedList, total: realTotal, limit: pageSize, offset: 0 }]
|
||||
},
|
||||
{ revalidate: false }
|
||||
)
|
||||
try {
|
||||
await client.reorderSessions(agentId, orderedIds)
|
||||
} catch (error) {
|
||||
mutate()
|
||||
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.reorder.error.failed')))
|
||||
}
|
||||
},
|
||||
[agentId, client, mutate, pageSize, t]
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
total,
|
||||
@@ -143,6 +166,7 @@ export const useSessions = (agentId: string | null, pageSize = DEFAULT_PAGE_SIZE
|
||||
loadMore,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession
|
||||
deleteSession,
|
||||
reorderSessions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Failed to list agents."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Failed to reorder agents"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "The API server is enabled but not running properly."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Session",
|
||||
"label_other": "Sessions",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Failed to reorder sessions"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Failed to update the session"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "获取智能体列表失败"
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "智能体排序失败"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "API 服务器已启用但未正常运行。"
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "会话",
|
||||
"label_other": "会话",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "会话排序失败"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "更新会话失败"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "無法列出 Agent。"
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Agent 排序失敗"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "API 伺服器已啟用,但運作不正常。"
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "工作階段",
|
||||
"label_other": "工作階段",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "無法重新排序工作階段"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "無法更新工作階段"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Agent-Liste abrufen fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Fehler beim Neuanordnen der Agenten"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "API server is enabled but not running properly."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Sitzung",
|
||||
"label_other": "Sitzungen",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Fehler beim Neuordnen der Sitzungen"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Sitzung aktualisieren fehlgeschlagen"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Αποτυχία καταχώρησης πρακτόρων."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Αποτυχία αναδιάταξης πρακτόρων"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "Ο διακομιστής API είναι ενεργοποιημένος αλλά δεν λειτουργεί σωστά."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Συνεδρία",
|
||||
"label_other": "Συνεδρίες",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Αποτυχία αναδιάταξης συνεδριών"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Αποτυχία ενημέρωσης της συνεδρίας"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Error al listar agentes."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Error al reordenar los agentes"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "El servidor de API está habilitado pero no funciona correctamente."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Sesión",
|
||||
"label_other": "Sesiones",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Error al reordenar las sesiones"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Error al actualizar la sesión"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Échec de la liste des agents."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Échec du réordonnancement des agents"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "Le serveur API est activé mais ne fonctionne pas correctement."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Session",
|
||||
"label_other": "Séances",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Échec du réordonnancement des sessions"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Échec de la mise à jour de la session"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "エージェントの一覧取得に失敗しました。"
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "エージェントの並び替えに失敗しました"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "APIサーバーは有効になっていますが、正常に動作していません。"
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "セッション",
|
||||
"label_other": "セッション",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "セッションの並び替えに失敗しました"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "セッションの更新に失敗しました"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Falha ao listar agentes."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Falha ao reordenar agentes"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "O servidor de API está habilitado, mas não está funcionando corretamente."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Sessão",
|
||||
"label_other": "Sessões",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Falha ao reordenar as sessões"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Falha ao atualizar a sessão"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Nu s-a putut afișa lista de agenți."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Nu s-a reușit reordonarea agenților"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "Serverul API este activat, dar nu rulează corect."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Sesiune",
|
||||
"label_other": "Sesiuni",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Nu s-au putut reordona sesiunile"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Nu s-a putut actualiza sesiunea"
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
"failed": "Не удалось получить список агентов."
|
||||
}
|
||||
},
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Не удалось изменить порядок агентов"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"error": {
|
||||
"not_running": "API-сервер включен, но работает неправильно."
|
||||
@@ -133,6 +138,11 @@
|
||||
},
|
||||
"label_one": "Сессия",
|
||||
"label_other": "Сессии",
|
||||
"reorder": {
|
||||
"error": {
|
||||
"failed": "Не удалось изменить порядок сеансов"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "Не удалось обновить сеанс"
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import AddButton from '@renderer/components/AddButton'
|
||||
import AgentModalPopup from '@renderer/components/Popups/agent/AgentModal'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
|
||||
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
||||
import { useApiServer } from '@renderer/hooks/useApiServer'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import type { AgentEntity } from '@renderer/types'
|
||||
import { cn } from '@renderer/utils'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AgentItem from './components/AgentItem'
|
||||
import Agents from './components/Agents'
|
||||
import Sessions from './components/Sessions'
|
||||
|
||||
interface AgentSidePanelProps {
|
||||
@@ -21,34 +14,14 @@ interface AgentSidePanelProps {
|
||||
|
||||
const AgentSidePanel = ({ onSelectItem }: AgentSidePanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { agents, deleteAgent, isLoading, error } = useAgents()
|
||||
const { apiServerRunning, startApiServer } = useApiServer()
|
||||
const { chat } = useRuntime()
|
||||
const { activeAgentId } = chat
|
||||
const { setActiveAgentId } = useActiveAgent()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
const { topicPosition } = useSettings()
|
||||
|
||||
const sessionsOnRight = topicPosition === 'right'
|
||||
const [tab, setTab] = useState<'agents' | 'sessions'>('agents')
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(agentId: string) => {
|
||||
setActiveAgentId(agentId)
|
||||
onSelectItem?.()
|
||||
},
|
||||
[setActiveAgentId, onSelectItem]
|
||||
)
|
||||
|
||||
const handleAddAgent = useCallback(() => {
|
||||
!apiServerRunning && startApiServer()
|
||||
AgentModalPopup.show({
|
||||
afterSubmit: (agent: AgentEntity) => {
|
||||
setActiveAgentId(agent.id)
|
||||
}
|
||||
})
|
||||
}, [apiServerRunning, startApiServer, setActiveAgentId])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col overflow-hidden"
|
||||
@@ -74,30 +47,7 @@ const AgentSidePanel = ({ onSelectItem }: AgentSidePanelProps) => {
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{(sessionsOnRight || tab === 'agents') && (
|
||||
<Scrollbar className="flex flex-col py-3">
|
||||
<div className="-mt-0.5 mb-1.5 px-2.5">
|
||||
<AddButton onClick={handleAddAgent}>{t('agent.sidebar_title')}</AddButton>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-2.5">
|
||||
{isLoading && (
|
||||
<div className="p-5 text-center text-(--color-text-secondary) text-[13px]">{t('common.loading')}</div>
|
||||
)}
|
||||
{error && <div className="p-5 text-center text-(--color-error) text-[13px]">{error.message}</div>}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
agents?.map((agent) => (
|
||||
<AgentItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={agent.id === activeAgentId}
|
||||
onDelete={() => deleteAgent(agent.id)}
|
||||
onPress={() => handleAgentPress(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
)}
|
||||
{(sessionsOnRight || tab === 'agents') && <Agents onSelectItem={onSelectItem} />}
|
||||
{!sessionsOnRight && tab === 'sessions' && activeAgentId && (
|
||||
<Sessions agentId={activeAgentId} onSelectItem={onSelectItem} />
|
||||
)}
|
||||
|
||||
79
src/renderer/src/pages/agents/components/Agents.tsx
Normal file
79
src/renderer/src/pages/agents/components/Agents.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import AddButton from '@renderer/components/AddButton'
|
||||
import DraggableVirtualList from '@renderer/components/DraggableList/virtual-list'
|
||||
import AgentModalPopup from '@renderer/components/Popups/agent/AgentModal'
|
||||
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
|
||||
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
||||
import { useApiServer } from '@renderer/hooks/useApiServer'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import type { AgentEntity } from '@renderer/types'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AgentItem from './AgentItem'
|
||||
|
||||
interface AgentsProps {
|
||||
onSelectItem?: () => void
|
||||
}
|
||||
|
||||
const Agents = ({ onSelectItem }: AgentsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { agents, deleteAgent, isLoading, error, reorderAgents } = useAgents()
|
||||
const { apiServerRunning, startApiServer } = useApiServer()
|
||||
const { chat } = useRuntime()
|
||||
const { activeAgentId } = chat
|
||||
const { setActiveAgentId } = useActiveAgent()
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(agentId: string) => {
|
||||
setActiveAgentId(agentId)
|
||||
onSelectItem?.()
|
||||
},
|
||||
[setActiveAgentId, onSelectItem]
|
||||
)
|
||||
|
||||
const handleAddAgent = useCallback(() => {
|
||||
!apiServerRunning && startApiServer()
|
||||
AgentModalPopup.show({
|
||||
afterSubmit: (agent: AgentEntity) => {
|
||||
setActiveAgentId(agent.id)
|
||||
}
|
||||
})
|
||||
}, [apiServerRunning, startApiServer, setActiveAgentId])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-5 text-center text-(--color-text-secondary) text-[13px]">{t('common.loading')}</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-5 text-center text-(--color-error) text-[13px]">{error.message}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<DraggableVirtualList
|
||||
className="agents-tab flex min-h-0 flex-1 flex-col"
|
||||
itemStyle={{ marginBottom: 8 }}
|
||||
list={agents ?? []}
|
||||
estimateSize={() => 9 * 4}
|
||||
scrollerStyle={{ overflowX: 'hidden', padding: '12px 10px' }}
|
||||
onUpdate={reorderAgents}
|
||||
itemKey={(index) => (agents ?? [])[index]?.id ?? index}
|
||||
header={
|
||||
<div className="-mt-0.5 mb-1.5">
|
||||
<AddButton onClick={handleAddAgent}>{t('agent.sidebar_title')}</AddButton>
|
||||
</div>
|
||||
}>
|
||||
{(agent) => (
|
||||
<AgentItem
|
||||
agent={agent}
|
||||
isActive={agent.id === activeAgentId}
|
||||
onDelete={() => deleteAgent(agent.id)}
|
||||
onPress={() => handleAgentPress(agent.id)}
|
||||
/>
|
||||
)}
|
||||
</DraggableVirtualList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Agents)
|
||||
@@ -209,7 +209,6 @@ const SessionListItem = styled.div`
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.menu {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AddButton from '@renderer/components/AddButton'
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
|
||||
import DraggableVirtualList, { type DraggableVirtualListRef } from '@renderer/components/DraggableList/virtual-list'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
@@ -13,7 +13,6 @@ import { motion } from 'framer-motion'
|
||||
import { throttle } from 'lodash'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SessionItem from './SessionItem'
|
||||
|
||||
@@ -27,13 +26,23 @@ const SCROLL_THROTTLE_DELAY = 150
|
||||
|
||||
const Sessions = ({ agentId, onSelectItem }: SessionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { sessions, isLoading, error, deleteSession, hasMore, loadMore, isLoadingMore, isValidating, reload } =
|
||||
useSessions(agentId)
|
||||
const {
|
||||
sessions,
|
||||
isLoading,
|
||||
error,
|
||||
deleteSession,
|
||||
hasMore,
|
||||
loadMore,
|
||||
isLoadingMore,
|
||||
isValidating,
|
||||
reload,
|
||||
reorderSessions
|
||||
} = useSessions(agentId)
|
||||
const { chat } = useRuntime()
|
||||
const { activeSessionIdMap } = chat
|
||||
const dispatch = useAppDispatch()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
const listRef = useRef<DynamicVirtualListRef>(null)
|
||||
const listRef = useRef<DraggableVirtualListRef>(null)
|
||||
|
||||
// Use refs to always read the latest values inside the throttled handler,
|
||||
// avoiding stale closures caused by recreating the throttle on each render.
|
||||
@@ -151,17 +160,18 @@ const Sessions = ({ agentId, onSelectItem }: SessionsProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<StyledVirtualList
|
||||
<DraggableVirtualList
|
||||
ref={listRef}
|
||||
className="sessions-tab"
|
||||
className="sessions-tab flex min-h-0 flex-1 flex-col"
|
||||
itemStyle={{ marginBottom: 8 }}
|
||||
list={sessions}
|
||||
estimateSize={() => 9 * 4}
|
||||
// FIXME: This component only supports CSSProperties
|
||||
scrollerStyle={{ overflowX: 'hidden' }}
|
||||
autoHideScrollbar
|
||||
scrollerStyle={{ overflowX: 'hidden', padding: '12px 10px' }}
|
||||
onUpdate={reorderSessions}
|
||||
itemKey={(index) => sessions[index]?.id ?? index}
|
||||
header={
|
||||
<div className="mt-0.5">
|
||||
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-1 mb-1.5">
|
||||
<div className="-mt-0.5 mb-1.5">
|
||||
<AddButton onClick={createDefaultSession} disabled={creatingSession}>
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
</div>
|
||||
@@ -178,7 +188,7 @@ const Sessions = ({ agentId, onSelectItem }: SessionsProps) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledVirtualList>
|
||||
</DraggableVirtualList>
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Spin size="small" />
|
||||
@@ -188,12 +198,4 @@ const Sessions = ({ agentId, onSelectItem }: SessionsProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const StyledVirtualList = styled(DynamicVirtualList)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
` as typeof DynamicVirtualList
|
||||
|
||||
export default memo(Sessions)
|
||||
|
||||
@@ -124,7 +124,7 @@ export const isAgentEntity = (value: unknown): value is AgentEntity => {
|
||||
export interface ListOptions {
|
||||
limit?: number
|
||||
offset?: number
|
||||
sortBy?: 'created_at' | 'updated_at' | 'name'
|
||||
sortBy?: 'created_at' | 'updated_at' | 'name' | 'sort_order'
|
||||
orderBy?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user