Skip to content

Commit f864920

Browse files
committed
feat: Agent 模式支持置顶会话功能
1、Agent 模式下添加置顶会话功能。 2、用户可以通过右键当前会话以及点击会话右上角的pin图标,将当前会话置顶。 3、置顶会话支持折叠,节省空间。 4、置顶会话采用隔离工作区的方式,不同工作区不共享置顶会话。 1. **类型定义**:在 AgentSessionMeta 中添加 `pinned?: boolean` 字段 2. **服务层**:在 agent-session-manager.ts 中支持置顶/取消置顶操作,排序时置顶项优先 3. **IPC 层**:添加 TOGGLE_PIN 通道,在 ipc.ts 和 preload/index.ts 中实现 4. **UI 层**: - 侧边栏添加置顶会话区域(可展开/收起) - 头部添加置顶按钮 - 右键菜单支持置顶/取消置顶 - 置顶会话按工作区隔离(从 filteredAgentSessions 而非 agentSessions 中过滤) - packages/shared/src/types/agent.ts - apps/electron/src/main/lib/agent-session-manager.ts - apps/electron/src/main/ipc.ts - apps/electron/src/preload/index.ts - apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx - apps/electron/src/renderer/components/agent/AgentHeader.tsx - [x] 右键会话,点击"置顶会话",会话移至置顶区域 - [x] 点击头部 Pin 按钮,当前会话置顶/取消置顶 - [x] 置顶会话在日期分组列表中显示,前面有 Pin 图标 - [x] 点击"置顶会话"标题,区域可展开/收起 - [x] 切换工作区,置顶会话按工作区隔离显示 - [x] 重启应用,置顶状态持久化保存 - [x] 右键置顶会话,点击"取消置顶",会话恢复普通状态
1 parent 861a142 commit f864920

6 files changed

Lines changed: 155 additions & 30 deletions

File tree

apps/electron/src/main/ipc.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,17 @@ export function registerIpcHandlers(): void {
501501
}
502502
)
503503

504+
// 切换 Agent 会话置顶状态
505+
ipcMain.handle(
506+
AGENT_IPC_CHANNELS.TOGGLE_PIN,
507+
async (_, id: string): Promise<AgentSessionMeta> => {
508+
const sessions = listAgentSessions()
509+
const current = sessions.find((s) => s.id === id)
510+
if (!current) throw new Error(`Agent 会话不存在: ${id}`)
511+
return updateAgentSessionMeta(id, { pinned: !current.pinned })
512+
}
513+
)
514+
504515
// ===== Agent 工作区管理相关 =====
505516

506517
// 确保默认工作区存在

apps/electron/src/main/lib/agent-session-manager.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,17 @@ function writeIndex(index: AgentSessionsIndex): void {
6666
}
6767

6868
/**
69-
* 获取所有会话( updatedAt 降序)
69+
* 获取所有会话(置顶项优先,然后按 updatedAt 降序)
7070
*/
7171
export function listAgentSessions(): AgentSessionMeta[] {
7272
const index = readIndex()
73-
return index.sessions.sort((a, b) => b.updatedAt - a.updatedAt)
73+
return index.sessions.sort((a, b) => {
74+
// 置顶项优先
75+
if (a.pinned && !b.pinned) return -1
76+
if (!a.pinned && b.pinned) return 1
77+
// 同为置顶或同为非置顶,按更新时间降序
78+
return b.updatedAt - a.updatedAt
79+
})
7480
}
7581

7682
/**
@@ -159,7 +165,7 @@ export function appendAgentMessage(id: string, message: AgentMessage): void {
159165
*/
160166
export function updateAgentSessionMeta(
161167
id: string,
162-
updates: Partial<Pick<AgentSessionMeta, 'title' | 'channelId' | 'sdkSessionId' | 'workspaceId'>>,
168+
updates: Partial<Pick<AgentSessionMeta, 'title' | 'channelId' | 'sdkSessionId' | 'workspaceId' | 'pinned'>>,
163169
): AgentSessionMeta {
164170
const index = readIndex()
165171
const idx = index.sessions.findIndex((s) => s.id === id)

apps/electron/src/preload/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ export interface ElectronAPI {
221221
/** 删除 Agent 会话 */
222222
deleteAgentSession: (id: string) => Promise<void>
223223

224+
/** 切换 Agent 会话置顶状态 */
225+
togglePinAgentSession: (id: string) => Promise<AgentSessionMeta>
226+
224227
/** 生成 Agent 会话标题 */
225228
generateAgentTitle: (input: AgentGenerateTitleInput) => Promise<string | null>
226229

@@ -543,6 +546,10 @@ const electronAPI: ElectronAPI = {
543546
return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DELETE_SESSION, id)
544547
},
545548

549+
togglePinAgentSession: (id: string) => {
550+
return ipcRenderer.invoke(AGENT_IPC_CHANNELS.TOGGLE_PIN, id)
551+
},
552+
546553
generateAgentTitle: (input: AgentGenerateTitleInput) => {
547554
return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GENERATE_TITLE, input)
548555
},

apps/electron/src/renderer/components/agent/AgentHeader.tsx

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,11 @@
77

88
import * as React from 'react'
99
import { useAtomValue, useSetAtom } from 'jotai'
10-
import { Pencil, Check, X, FolderOpen } from 'lucide-react'
10+
import { Pencil, Check, X, Pin, FolderOpen } from 'lucide-react'
11+
import { currentAgentSessionAtom, agentSessionsAtom } from '@/atoms/agent-atoms'
1112
import { Button } from '@/components/ui/button'
12-
import {
13-
Tooltip,
14-
TooltipContent,
15-
TooltipTrigger,
16-
} from '@/components/ui/tooltip'
13+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
1714
import { cn } from '@/lib/utils'
18-
import { currentAgentSessionAtom, agentSessionsAtom } from '@/atoms/agent-atoms'
1915

2016
interface AgentHeaderProps {
2117
onToggleFileBrowser: () => void
@@ -108,26 +104,48 @@ export function AgentHeader({ onToggleFileBrowser, fileBrowserOpen }: AgentHeade
108104
</button>
109105
)}
110106

111-
{/* 文件浏览器切换 */}
112-
<Tooltip>
113-
<TooltipTrigger asChild>
114-
<Button
115-
type="button"
116-
variant="ghost"
117-
size="icon"
118-
className={cn(
119-
'h-8 w-8 flex-shrink-0',
120-
fileBrowserOpen && 'bg-accent text-accent-foreground'
121-
)}
122-
onClick={onToggleFileBrowser}
123-
>
124-
<FolderOpen className="size-4" />
125-
</Button>
126-
</TooltipTrigger>
127-
<TooltipContent side="bottom">
128-
<p>{fileBrowserOpen ? '关闭文件浏览器' : '打开文件浏览器'}</p>
129-
</TooltipContent>
130-
</Tooltip>
107+
{/* 右侧按钮组 */}
108+
<div className="flex items-center gap-1 titlebar-no-drag ml-auto">
109+
{/* 置顶按钮 */}
110+
<Tooltip>
111+
<TooltipTrigger asChild>
112+
<Button
113+
type="button"
114+
variant="ghost"
115+
size="icon"
116+
className={cn('h-7 w-7', session.pinned && 'bg-accent text-accent-foreground')}
117+
onClick={async () => {
118+
const updated = await window.electronAPI.togglePinAgentSession(session.id)
119+
setAgentSessions((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
120+
}}
121+
>
122+
<Pin className="size-3.5" />
123+
</Button>
124+
</TooltipTrigger>
125+
<TooltipContent side="bottom"><p>{session.pinned ? '取消置顶' : '置顶会话'}</p></TooltipContent>
126+
</Tooltip>
127+
128+
{/* 文件浏览器切换 */}
129+
<Tooltip>
130+
<TooltipTrigger asChild>
131+
<Button
132+
type="button"
133+
variant="ghost"
134+
size="icon"
135+
className={cn(
136+
'h-8 w-8 flex-shrink-0',
137+
fileBrowserOpen && 'bg-accent text-accent-foreground'
138+
)}
139+
onClick={onToggleFileBrowser}
140+
>
141+
<FolderOpen className="size-4" />
142+
</Button>
143+
</TooltipTrigger>
144+
<TooltipContent side="bottom">
145+
<p>{fileBrowserOpen ? '关闭文件浏览器' : '打开文件浏览器'}</p>
146+
</TooltipContent>
147+
</Tooltip>
148+
</div>
131149
</div>
132150
)
133151
}

apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
138138
const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
139139
/** 置顶区域展开/收起 */
140140
const [pinnedExpanded, setPinnedExpanded] = React.useState(true)
141+
/** Agent 置顶区域展开/收起 */
142+
const [pinnedAgentExpanded, setPinnedAgentExpanded] = React.useState(true)
141143
const setUserProfile = useSetAtom(userProfileAtom)
142144
const selectedModel = useAtomValue(selectedModelAtom)
143145
const streamingIds = useAtomValue(streamingConversationIdsAtom)
@@ -280,6 +282,18 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
280282
}
281283
}
282284

285+
/** 切换 Agent 会话置顶状态 */
286+
const handleTogglePinAgent = async (id: string): Promise<void> => {
287+
try {
288+
const updated = await window.electronAPI.togglePinAgentSession(id)
289+
setAgentSessions((prev) =>
290+
prev.map((s) => (s.id === updated.id ? updated : s))
291+
)
292+
} catch (error) {
293+
console.error('[侧边栏] 切换 Agent 会话置顶失败:', error)
294+
}
295+
}
296+
283297
/** 确认删除对话 */
284298
const handleConfirmDelete = async (): Promise<void> => {
285299
if (!pendingDeleteId) return
@@ -355,6 +369,12 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
355369
[agentSessions, currentWorkspaceId]
356370
)
357371

372+
/** 置顶 Agent 会话列表(按当前工作区过滤) */
373+
const pinnedAgentSessions = React.useMemo(
374+
() => filteredAgentSessions.filter((s) => s.pinned),
375+
[filteredAgentSessions]
376+
)
377+
358378
/** Agent 会话按日期分组 */
359379
const agentSessionGroups = React.useMemo(
360380
() => groupByDate(filteredAgentSessions),
@@ -408,6 +428,22 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
408428
</div>
409429
)}
410430

431+
{/* Agent 模式:导航菜单(置顶区域) */}
432+
{mode === 'agent' && pinnedAgentSessions.length > 0 && (
433+
<div className="flex flex-col gap-1 pt-3 px-3">
434+
<SidebarItem
435+
icon={<Pin size={16} />}
436+
label="置顶会话"
437+
suffix={
438+
pinnedAgentExpanded
439+
? <ChevronDown size={14} className="text-foreground/40" />
440+
: <ChevronRight size={14} className="text-foreground/40" />
441+
}
442+
onClick={() => setPinnedAgentExpanded(!pinnedAgentExpanded)}
443+
/>
444+
</div>
445+
)}
446+
411447
{/* Chat 模式:置顶对话区域 */}
412448
{mode === 'chat' && pinnedExpanded && pinnedConversations.length > 0 && (
413449
<div className="px-3 pt-1 pb-1">
@@ -432,6 +468,30 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
432468
</div>
433469
)}
434470

471+
{/* Agent 模式:置顶会话区域 */}
472+
{mode === 'agent' && pinnedAgentExpanded && pinnedAgentSessions.length > 0 && (
473+
<div className="px-3 pt-1 pb-1">
474+
<div className="flex flex-col gap-0.5 pl-1 border-l-2 border-primary/20 ml-2">
475+
{pinnedAgentSessions.map((session) => (
476+
<AgentSessionItem
477+
key={`pinned-${session.id}`}
478+
session={session}
479+
active={session.id === currentAgentSessionId}
480+
hovered={session.id === hoveredId}
481+
running={agentRunningIds.has(session.id)}
482+
showPinIcon={false}
483+
onSelect={() => handleSelectAgentSession(session.id)}
484+
onRequestDelete={() => handleRequestDelete(session.id)}
485+
onRename={handleAgentRename}
486+
onTogglePin={handleTogglePinAgent}
487+
onMouseEnter={() => setHoveredId(session.id)}
488+
onMouseLeave={() => setHoveredId(null)}
489+
/>
490+
))}
491+
</div>
492+
</div>
493+
)}
494+
435495
{/* 列表区域:根据模式切换 */}
436496
<div className="flex-1 overflow-y-auto px-3 pt-2 pb-3 scrollbar-none">
437497
{mode === 'chat' ? (
@@ -476,9 +536,11 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
476536
active={session.id === currentAgentSessionId}
477537
hovered={session.id === hoveredId}
478538
running={agentRunningIds.has(session.id)}
539+
showPinIcon={!!session.pinned}
479540
onSelect={() => handleSelectAgentSession(session.id)}
480541
onRequestDelete={() => handleRequestDelete(session.id)}
481542
onRename={handleAgentRename}
543+
onTogglePin={handleTogglePinAgent}
482544
onMouseEnter={() => setHoveredId(session.id)}
483545
onMouseLeave={() => setHoveredId(null)}
484546
/>
@@ -728,9 +790,11 @@ interface AgentSessionItemProps {
728790
active: boolean
729791
hovered: boolean
730792
running: boolean
793+
showPinIcon?: boolean
731794
onSelect: () => void
732795
onRequestDelete: () => void
733796
onRename: (id: string, newTitle: string) => Promise<void>
797+
onTogglePin?: (id: string) => void
734798
onMouseEnter: () => void
735799
onMouseLeave: () => void
736800
}
@@ -740,16 +804,20 @@ function AgentSessionItem({
740804
active,
741805
hovered,
742806
running,
807+
showPinIcon,
743808
onSelect,
744809
onRequestDelete,
745810
onRename,
811+
onTogglePin,
746812
onMouseEnter,
747813
onMouseLeave,
748814
}: AgentSessionItemProps): React.ReactElement {
749815
const [editing, setEditing] = React.useState(false)
750816
const [editTitle, setEditTitle] = React.useState('')
751817
const inputRef = React.useRef<HTMLInputElement>(null)
752818

819+
const isPinned = !!session.pinned
820+
753821
const startEdit = (): void => {
754822
setEditTitle(session.title)
755823
setEditing(true)
@@ -821,6 +889,10 @@ function AgentSessionItem({
821889
<span className="relative block size-2 rounded-full bg-blue-500" />
822890
</span>
823891
)}
892+
{/* 置顶标记 */}
893+
{showPinIcon && (
894+
<Pin size={11} className="flex-shrink-0 text-primary/60" />
895+
)}
824896
<span className="truncate">{session.title}</span>
825897
</div>
826898
)}
@@ -844,6 +916,13 @@ function AgentSessionItem({
844916
</ContextMenuTrigger>
845917

846918
<ContextMenuContent className="w-40">
919+
<ContextMenuItem
920+
className="gap-2 text-[13px]"
921+
onSelect={() => onTogglePin?.(session.id)}
922+
>
923+
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
924+
{isPinned ? '取消置顶' : '置顶会话'}
925+
</ContextMenuItem>
847926
<ContextMenuItem
848927
className="gap-2 text-[13px]"
849928
onSelect={startEdit}

packages/shared/src/types/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export interface AgentSessionMeta {
7777
sdkSessionId?: string
7878
/** 所属工作区 ID */
7979
workspaceId?: string
80+
/** 是否置顶 */
81+
pinned?: boolean
8082
/** 创建时间戳 */
8183
createdAt: number
8284
/** 更新时间戳 */
@@ -251,6 +253,8 @@ export const AGENT_IPC_CHANNELS = {
251253
UPDATE_TITLE: 'agent:update-title',
252254
/** 删除会话 */
253255
DELETE_SESSION: 'agent:delete-session',
256+
/** 切换会话置顶状态 */
257+
TOGGLE_PIN: 'agent:toggle-pin',
254258

255259
// 工作区管理
256260
/** 获取工作区列表 */

0 commit comments

Comments
 (0)