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:
Phantom
2026-03-15 09:41:11 +08:00
committed by GitHub
parent f0f04eeda0
commit e860dd448f
31 changed files with 994 additions and 101 deletions

View 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;

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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",

View File

@@ -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'
}
})
}
}

View File

@@ -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 {

View File

@@ -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)
/**

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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 {

View File

@@ -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={{

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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"

View File

@@ -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": "更新会话失败"

View File

@@ -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": "無法更新工作階段"

View File

@@ -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"

View File

@@ -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": "Αποτυχία ενημέρωσης της συνεδρίας"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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": "セッションの更新に失敗しました"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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": "Не удалось обновить сеанс"

View File

@@ -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} />
)}

View 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)

View File

@@ -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;

View File

@@ -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)

View File

@@ -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'
}