Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,17 @@ export function registerIpcHandlers(): void {
}
)

// 切换 Agent 会话置顶状态
ipcMain.handle(
AGENT_IPC_CHANNELS.TOGGLE_PIN,
async (_, id: string): Promise<AgentSessionMeta> => {
const sessions = listAgentSessions()
const current = sessions.find((s) => s.id === id)
if (!current) throw new Error(`Agent 会话不存在: ${id}`)
return updateAgentSessionMeta(id, { pinned: !current.pinned })
}
)

// ===== Agent 工作区管理相关 =====

// 确保默认工作区存在
Expand Down
12 changes: 9 additions & 3 deletions apps/electron/src/main/lib/agent-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@ function writeIndex(index: AgentSessionsIndex): void {
}

/**
* 获取所有会话( updatedAt 降序)
* 获取所有会话(置顶项优先,然后按 updatedAt 降序)
*/
export function listAgentSessions(): AgentSessionMeta[] {
const index = readIndex()
return index.sessions.sort((a, b) => b.updatedAt - a.updatedAt)
return index.sessions.sort((a, b) => {
// 置顶项优先
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
// 同为置顶或同为非置顶,按更新时间降序
return b.updatedAt - a.updatedAt
})
}

/**
Expand Down Expand Up @@ -159,7 +165,7 @@ export function appendAgentMessage(id: string, message: AgentMessage): void {
*/
export function updateAgentSessionMeta(
id: string,
updates: Partial<Pick<AgentSessionMeta, 'title' | 'channelId' | 'sdkSessionId' | 'workspaceId'>>,
updates: Partial<Pick<AgentSessionMeta, 'title' | 'channelId' | 'sdkSessionId' | 'workspaceId' | 'pinned'>>,
): AgentSessionMeta {
const index = readIndex()
const idx = index.sessions.findIndex((s) => s.id === id)
Expand Down
7 changes: 7 additions & 0 deletions apps/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ export interface ElectronAPI {
/** 删除 Agent 会话 */
deleteAgentSession: (id: string) => Promise<void>

/** 切换 Agent 会话置顶状态 */
togglePinAgentSession: (id: string) => Promise<AgentSessionMeta>

/** 生成 Agent 会话标题 */
generateAgentTitle: (input: AgentGenerateTitleInput) => Promise<string | null>

Expand Down Expand Up @@ -660,6 +663,10 @@ const electronAPI: ElectronAPI = {
return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DELETE_SESSION, id)
},

togglePinAgentSession: (id: string) => {
return ipcRenderer.invoke(AGENT_IPC_CHANNELS.TOGGLE_PIN, id)
},

generateAgentTitle: (input: AgentGenerateTitleInput) => {
return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GENERATE_TITLE, input)
},
Expand Down
26 changes: 25 additions & 1 deletion apps/electron/src/renderer/components/agent/AgentHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@

import * as React from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { Pencil, Check, X } from 'lucide-react'
import { Pencil, Check, X, Pin } from 'lucide-react'
import { currentAgentSessionAtom, agentSessionsAtom } from '@/atoms/agent-atoms'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'

export function AgentHeader(): React.ReactElement | null {
const session = useAtomValue(currentAgentSessionAtom)
Expand Down Expand Up @@ -101,6 +104,27 @@ export function AgentHeader(): React.ReactElement | null {
</button>
</div>
)}

{/* 右侧按钮组 */}
<div className="flex items-center gap-1 titlebar-no-drag ml-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn('h-7 w-7', session.pinned && 'bg-accent text-accent-foreground')}
onClick={async () => {
const updated = await window.electronAPI.togglePinAgentSession(session.id)
setAgentSessions((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
}}
>
<Pin className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>{session.pinned ? '取消置顶' : '置顶会话'}</p></TooltipContent>
</Tooltip>
</div>
</div>
)
}
107 changes: 95 additions & 12 deletions apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
/** 置顶区域展开/收起 */
const [pinnedExpanded, setPinnedExpanded] = React.useState(true)
/** Agent 置顶区域展开/收起 */
const [pinnedAgentExpanded, setPinnedAgentExpanded] = React.useState(true)
const setUserProfile = useSetAtom(userProfileAtom)
const selectedModel = useAtomValue(selectedModelAtom)
const streamingIds = useAtomValue(streamingConversationIdsAtom)
Expand Down Expand Up @@ -289,6 +291,18 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
}
}

/** 切换 Agent 会话置顶状态 */
const handleTogglePinAgent = async (id: string): Promise<void> => {
try {
const updated = await window.electronAPI.togglePinAgentSession(id)
setAgentSessions((prev) =>
prev.map((s) => (s.id === updated.id ? updated : s))
)
} catch (error) {
console.error('[侧边栏] 切换 Agent 会话置顶失败:', error)
}
}

/** 确认删除对话 */
const handleConfirmDelete = async (): Promise<void> => {
if (!pendingDeleteId) return
Expand Down Expand Up @@ -364,6 +378,12 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
[agentSessions, currentWorkspaceId]
)

/** 置顶 Agent 会话列表(按当前工作区过滤) */
const pinnedAgentSessions = React.useMemo(
() => filteredAgentSessions.filter((s) => s.pinned),
[filteredAgentSessions]
)

/** Agent 会话按日期分组 */
const agentSessionGroups = React.useMemo(
() => groupByDate(filteredAgentSessions),
Expand Down Expand Up @@ -401,19 +421,39 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {

{/* Chat 模式:导航菜单(置顶区域) */}
{mode === 'chat' && (
<div className="flex flex-col gap-1 pt-3 px-3">
<SidebarItem
icon={<Pin size={16} />}
label="置顶对话"
suffix={
pinnedConversations.length > 0 ? (
pinnedExpanded
? <ChevronDown size={14} className="text-foreground/40" />
: <ChevronRight size={14} className="text-foreground/40" />
) : undefined
}
<div className="px-3 pt-3">
<button
onClick={() => handleItemClick('pinned')}
/>
className="w-full flex items-center justify-between px-3 py-2 rounded-[10px] text-[13px] transition-colors duration-100 titlebar-no-drag text-foreground/60 hover:bg-foreground/[0.04] dark:hover:bg-foreground/[0.04] hover:text-foreground"
>
<div className="flex items-center gap-2">
<Pin size={14} />
<span>置顶对话</span>
</div>
{pinnedConversations.length > 0 && (
pinnedExpanded
? <ChevronDown size={14} className="text-foreground/40" />
: <ChevronRight size={14} className="text-foreground/40" />
)}
</button>
</div>
)}

{/* Agent 模式:导航菜单(置顶区域) */}
{mode === 'agent' && pinnedAgentSessions.length > 0 && (
<div className="px-3 pt-3">
<button
onClick={() => setPinnedAgentExpanded(!pinnedAgentExpanded)}
className="w-full flex items-center justify-between px-3 py-2 rounded-[10px] text-[13px] transition-colors duration-100 titlebar-no-drag text-foreground/60 hover:bg-foreground/[0.04] dark:hover:bg-foreground/[0.04] hover:text-foreground"
>
<div className="flex items-center gap-2">
<Pin size={14} />
<span>置顶会话</span>
</div>
{pinnedAgentExpanded
? <ChevronDown size={14} className="text-foreground/40" />
: <ChevronRight size={14} className="text-foreground/40" />}
</button>
</div>
)}

Expand Down Expand Up @@ -441,6 +481,30 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
</div>
)}

{/* Agent 模式:置顶会话区域 */}
{mode === 'agent' && pinnedAgentExpanded && pinnedAgentSessions.length > 0 && (
<div className="px-3 pt-1 pb-1">
<div className="flex flex-col gap-0.5 pl-1 border-l-2 border-primary/20 ml-2">
{pinnedAgentSessions.map((session) => (
<AgentSessionItem
key={`pinned-${session.id}`}
session={session}
active={session.id === currentAgentSessionId}
hovered={session.id === hoveredId}
running={agentRunningIds.has(session.id)}
showPinIcon={false}
onSelect={() => handleSelectAgentSession(session.id)}
onRequestDelete={() => handleRequestDelete(session.id)}
onRename={handleAgentRename}
onTogglePin={handleTogglePinAgent}
onMouseEnter={() => setHoveredId(session.id)}
onMouseLeave={() => setHoveredId(null)}
/>
))}
</div>
</div>
)}

{/* 列表区域:根据模式切换 */}
<div className="flex-1 overflow-y-auto px-3 pt-2 pb-3 scrollbar-none">
{mode === 'chat' ? (
Expand Down Expand Up @@ -485,9 +549,11 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement {
active={session.id === currentAgentSessionId}
hovered={session.id === hoveredId}
running={agentRunningIds.has(session.id)}
showPinIcon={!!session.pinned}
onSelect={() => handleSelectAgentSession(session.id)}
onRequestDelete={() => handleRequestDelete(session.id)}
onRename={handleAgentRename}
onTogglePin={handleTogglePinAgent}
onMouseEnter={() => setHoveredId(session.id)}
onMouseLeave={() => setHoveredId(null)}
/>
Expand Down Expand Up @@ -750,9 +816,11 @@ interface AgentSessionItemProps {
active: boolean
hovered: boolean
running: boolean
showPinIcon?: boolean
onSelect: () => void
onRequestDelete: () => void
onRename: (id: string, newTitle: string) => Promise<void>
onTogglePin?: (id: string) => void
onMouseEnter: () => void
onMouseLeave: () => void
}
Expand All @@ -762,9 +830,11 @@ function AgentSessionItem({
active,
hovered,
running,
showPinIcon,
onSelect,
onRequestDelete,
onRename,
onTogglePin,
onMouseEnter,
onMouseLeave,
}: AgentSessionItemProps): React.ReactElement {
Expand All @@ -773,6 +843,8 @@ function AgentSessionItem({
const inputRef = React.useRef<HTMLInputElement>(null)
const justStartedEditing = React.useRef(false)

const isPinned = !!session.pinned

const startEdit = (): void => {
setEditTitle(session.title)
setEditing(true)
Expand Down Expand Up @@ -847,6 +919,10 @@ function AgentSessionItem({
<span className="relative block size-2 rounded-full bg-blue-500" />
</span>
)}
{/* 置顶标记 */}
{showPinIcon && (
<Pin size={11} className="flex-shrink-0 text-primary/60" />
)}
<span className="truncate">{session.title}</span>
</div>
)}
Expand All @@ -870,6 +946,13 @@ function AgentSessionItem({
</ContextMenuTrigger>

<ContextMenuContent className="w-40">
<ContextMenuItem
className="gap-2 text-[13px]"
onSelect={() => onTogglePin?.(session.id)}
>
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
{isPinned ? '取消置顶' : '置顶会话'}
</ContextMenuItem>
<ContextMenuItem
className="gap-2 text-[13px]"
onSelect={startEdit}
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export interface AgentSessionMeta {
sdkSessionId?: string
/** 所属工作区 ID */
workspaceId?: string
/** 是否置顶 */
pinned?: boolean
/** 创建时间戳 */
createdAt: number
/** 更新时间戳 */
Expand Down Expand Up @@ -524,6 +526,8 @@ export const AGENT_IPC_CHANNELS = {
UPDATE_TITLE: 'agent:update-title',
/** 删除会话 */
DELETE_SESSION: 'agent:delete-session',
/** 切换会话置顶状态 */
TOGGLE_PIN: 'agent:toggle-pin',

// 工作区管理
/** 获取工作区列表 */
Expand Down