feat: 模型级联菜单、逐回合分叉、折叠态会话切换、划选引用与界面缩放

- 模型菜单改为级联悬浮结构:服务商独立成行,悬浮其上时模型列表浮出左侧;
This commit is contained in:
Rick Ye
2026-09-09 18:06:44 +08:00
parent 2622487279
commit 2b69a9aa13
17 changed files with 677 additions and 355 deletions
+7
View File
@@ -181,6 +181,13 @@ export function registerIpc(): AgentService {
// 会话末尾分叉为新会话(pi fork,原会话保留) // 会话末尾分叉为新会话(pi fork,原会话保留)
ipcMain.handle('session:fork', () => pass(() => service.forkSession())) ipcMain.handle('session:fork', () => pass(() => service.forkSession()))
// 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止)
ipcMain.handle('session:forkAt', (_event, entryId: unknown, position: unknown) => {
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
if (position !== 'at' && position !== 'before') throw new Error('invalid fork position')
return pass(() => service.forkSessionAt(entryId, position))
})
// ---- 应用设置 ---- // ---- 应用设置 ----
ipcMain.handle('settings:get', () => service.getSettings()) ipcMain.handle('settings:get', () => service.getSettings())
+43 -34
View File
@@ -2,13 +2,13 @@ import { app, dialog, shell } from 'electron'
import { nativeTheme } from 'electron' import { nativeTheme } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { import {
createAgentSessionRuntime, createAgentSessionRuntime,
createAgentSessionServices, createAgentSessionServices,
createAgentSessionFromServices, createAgentSessionFromServices,
estimateTokens, estimateTokens,
getAgentDir, getAgentDir,
loadSkills,
SessionManager, SessionManager,
type AgentSession, type AgentSession,
type AgentSessionEvent, type AgentSessionEvent,
@@ -476,7 +476,7 @@ export class AgentService {
] ]
const byKey = new Map(breakdown.map((b) => [b.key, b])) const byKey = new Map(breakdown.map((b) => [b.key, b]))
const systemTokens = Math.ceil(session.systemPrompt.length / 4) const systemTokens = Math.ceil(session.systemPrompt.length / 4)
const skillsTokens = this.estimateSkillsTokens() const skillsTokens = this.estimateSkillsTokens(session)
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens) byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
byKey.get('skills')!.tokens = skillsTokens byKey.get('skills')!.tokens = skillsTokens
@@ -522,15 +522,14 @@ export class AgentService {
} }
} }
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化) */ /** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。
private estimateSkillsTokens(): number { * 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */
private estimateSkillsTokens(session: AgentSession): number {
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
let value = 0 let value = 0
try { try {
const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true }) const loaded = session.resourceLoader.getSkills()
value = Math.ceil( value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4)
loaded.skills.reduce((sum, s) => sum + s.name.length + (s.description?.length ?? 0) + 8, 0) / 4
)
} catch { } catch {
value = 0 value = 0
} }
@@ -633,18 +632,11 @@ export class AgentService {
async listChatResources(): Promise<ChatResources> { async listChatResources(): Promise<ChatResources> {
const runtime = await this.getRuntime() const runtime = await this.getRuntime()
let skills: ChatResources['skills'] = [] // 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
try { const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
const loaded = loadSkills({ name: s.name,
cwd: this.cwd, description: s.description
agentDir: getAgentDir(), }))
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
} catch (err) {
console.error('[agent-service] load skills failed:', err)
}
const commands = runtime.session.promptTemplates.map((t) => ({ const commands = runtime.session.promptTemplates.map((t) => ({
name: t.name, name: t.name,
description: t.description ?? '', description: t.description ?? '',
@@ -653,30 +645,31 @@ export class AgentService {
return { commands, skills } return { commands, skills }
} }
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */ /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */
async listResourceDetails(): Promise<ResourceDetails> { async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir() const agentDir = getAgentDir()
const dirs = { const dirs = {
globalSkills: join(agentDir, 'skills'), globalSkills: join(agentDir, 'skills'),
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
projectSkills: join(this.cwd, '.pi', 'skills'), projectSkills: join(this.cwd, '.pi', 'skills'),
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
globalCommands: join(agentDir, 'prompts'), globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts') projectCommands: join(this.cwd, '.pi', 'prompts')
} }
let skills: SkillDetail[] = [] const skills: SkillDetail[] = []
try { try {
const loaded = loadSkills({ const runtime = await this.getRuntime()
cwd: this.cwd, for (const s of runtime.session.resourceLoader.getSkills().skills) {
agentDir, skills.push({
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({
name: s.name, name: s.name,
description: s.description ?? '', description: s.description,
filePath: s.filePath, filePath: s.filePath,
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project', // 与 resource loader 的默认归类一致:无来源信息时按用户级
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
disableModelInvocation: Boolean(s.disableModelInvocation) disableModelInvocation: Boolean(s.disableModelInvocation)
})) })
}
} catch (err) { } catch (err) {
console.error('[agent-service] load skill details failed:', err) console.error('[agent-service] load skill details failed:', err)
} }
@@ -953,6 +946,12 @@ export class AgentService {
const mr = runtime.session.modelRuntime const mr = runtime.session.modelRuntime
const credentials = await mr.listCredentials() const credentials = await mr.listCredentials()
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`)) const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
// 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置
const customKeyed = new Set(
Object.entries(this.readModelsConfig().providers)
.filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey))
.map(([id]) => id)
)
const providers = mr const providers = mr
.getProviders() .getProviders()
.map((p) => { .map((p) => {
@@ -960,7 +959,7 @@ export class AgentService {
return { return {
id: pid, id: pid,
name: String(p.name ?? pid), name: String(p.name ?? pid),
hasApiKey: credSet.has(`${pid}:api_key`), hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid),
isOAuth: credSet.has(`${pid}:oauth`), isOAuth: credSet.has(`${pid}:oauth`),
models: mr models: mr
.getModels(pid) .getModels(pid)
@@ -1076,7 +1075,17 @@ export class AgentService {
if (!leafId) { if (!leafId) {
throw new Error('会话还没有可分叉的内容') throw new Error('会话还没有可分叉的内容')
} }
const result = await runtime.fork(leafId, { position: 'at' }) await this.forkSessionAt(leafId, 'at')
}
/** 从会话树上的任意锚点分叉新会话:'at' = 新会话包含该条为止;'before' = 到其父条目为止 */
async forkSessionAt(entryId: string, position: 'at' | 'before'): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (session.isStreaming) {
await session.abort()
}
const result = await runtime.fork(entryId, { position })
if (result.cancelled) return if (result.cancelled) return
} }
+12 -1
View File
@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer, webFrame } from 'electron'
import type { import type {
AgentUiEvent, AgentUiEvent,
AppSettings, AppSettings,
@@ -28,6 +28,15 @@ const api = {
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'), minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'), toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
close: (): Promise<void> => ipcRenderer.invoke('win:close'), close: (): Promise<void> => ipcRenderer.invoke('win:close'),
// 界面缩放(Ctrl+= / Ctrl+- / Ctrl+0),factor 为相对倍率,限制在 50%–200%
zoom: (factor: number): void => {
const next = Math.min(2, Math.max(0.5, webFrame.getZoomFactor() * factor))
webFrame.setZoomFactor(next)
},
zoomReset: (): void => {
webFrame.setZoomFactor(1)
},
onWindowState: (callback: (maximized: boolean) => void): (() => void) => { onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized) const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
ipcRenderer.on('win:state', listener) ipcRenderer.on('win:state', listener)
@@ -78,6 +87,8 @@ const api = {
navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> => navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> =>
ipcRenderer.invoke('session:navigate', entryId, options), ipcRenderer.invoke('session:navigate', entryId, options),
forkSession: (): Promise<void> => ipcRenderer.invoke('session:fork'), forkSession: (): Promise<void> => ipcRenderer.invoke('session:fork'),
forkSessionAt: (entryId: string, position: 'at' | 'before'): Promise<void> =>
ipcRenderer.invoke('session:forkAt', entryId, position),
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'), getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'), getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id), setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
+22 -7
View File
@@ -82,7 +82,8 @@ export default function App(): JSX.Element {
setAutoCompaction, setAutoCompaction,
dismissCompactionNotice, dismissCompactionNotice,
navigateTree, navigateTree,
forkSession forkSession,
forkSessionAt
} = useAgent() } = useAgent()
const prefillRef = useRef<string | null>(null) const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
@@ -146,7 +147,7 @@ export default function App(): JSX.Element {
return stopWatching return stopWatching
}, []) }, [])
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗 // 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.ctrlKey && e.key.toLowerCase() === 'n') { if (e.ctrlKey && e.key.toLowerCase() === 'n') {
@@ -158,6 +159,15 @@ export default function App(): JSX.Element {
} else if (e.ctrlKey && e.key === ',') { } else if (e.ctrlKey && e.key === ',') {
e.preventDefault() e.preventDefault()
setSettingsOpen(true) setSettingsOpen(true)
} else if (e.ctrlKey && (e.key === '=' || e.key === '+')) {
e.preventDefault()
window.api.zoom(1.1)
} else if (e.ctrlKey && (e.key === '-' || e.key === '_')) {
e.preventDefault()
window.api.zoom(1 / 1.1)
} else if (e.ctrlKey && e.key === '0') {
e.preventDefault()
window.api.zoomReset()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setSettingsOpen(false) setSettingsOpen(false)
setSearchOpen(false) setSearchOpen(false)
@@ -171,10 +181,12 @@ export default function App(): JSX.Element {
prefillRef.current = text prefillRef.current = text
} }
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent // 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿)
const handleApplySelection = (instruction: string, selection: string): void => { const [pendingQuote, setPendingQuote] = useState<string | null>(null)
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`) const handleQuoteSelection = useCallback((selection: string) => {
} setPendingQuote(`> ${selection}\n\n`)
}, [])
const consumePendingQuote = useCallback(() => setPendingQuote(null), [])
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo) // 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
const handleRewind = useCallback( const handleRewind = useCallback(
@@ -261,8 +273,9 @@ export default function App(): JSX.Element {
onAbortCompaction={abortCompaction} onAbortCompaction={abortCompaction}
onRewind={handleRewind} onRewind={handleRewind}
onFork={forkSession} onFork={forkSession}
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
onPickSuggestion={handlePickSuggestion} onPickSuggestion={handlePickSuggestion}
onApplySelection={handleApplySelection} onQuoteSelection={handleQuoteSelection}
/> />
<BottomSlot <BottomSlot
approvals={state.approvals} approvals={state.approvals}
@@ -292,6 +305,8 @@ export default function App(): JSX.Element {
onAbortCompaction={abortCompaction} onAbortCompaction={abortCompaction}
onToggleAutoCompaction={setAutoCompaction} onToggleAutoCompaction={setAutoCompaction}
prefillRef={prefillRef} prefillRef={prefillRef}
pendingQuote={pendingQuote}
onQuoteConsumed={consumePendingQuote}
/> />
} }
/> />
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { LuFoldVertical, LuGitFork, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu' import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
import type { ApprovalRequest, ForkPoint } from '@shared/types' import type { ApprovalRequest, ForkPoint } from '@shared/types'
import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent' import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent'
import { formatTokens } from '../lib/utils' import { formatTokens } from '../lib/utils'
@@ -66,7 +66,10 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。 * pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序), * forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。 * startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
* animate=false(正在补齐历史)时挂载的消息不播入场动画。 */ * animate=false(正在补齐历史)时挂载的消息不播入场动画。
* 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合;
* 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before')
* ——新会话包含到本回合答案为止。 */
function renderTurns( function renderTurns(
messages: UiMessage[], messages: UiMessage[],
tools: Record<string, ToolState>, tools: Record<string, ToolState>,
@@ -78,11 +81,25 @@ function renderTurns(
rewinding: boolean, rewinding: boolean,
onRewind: (entryId: string, summarize: boolean) => void, onRewind: (entryId: string, summarize: boolean) => void,
startUserIdx = 0, startUserIdx = 0,
animate = true animate = true,
forking = false,
onFork?: () => void,
onForkAt?: (entryId: string) => void
): ReactNode[] { ): ReactNode[] {
const items: ReactNode[] = [] const items: ReactNode[] = []
let i = 0 let i = 0
let userMessageIdx = startUserIdx let userMessageIdx = startUserIdx
// 每条真实用户消息(非乐观插入)对应的分叉点,供回合级分叉取「下一条用户消息」的锚点
const userForkIds = new Map<number, string>()
for (let idx = 0; idx < messages.length; idx++) {
const m = messages[idx]
if (m.role === 'user' && !m.local) {
const fp = forkPoints[userMessageIdx]
userMessageIdx += 1
if (fp) userForkIds.set(idx, fp.entryId)
}
}
userMessageIdx = startUserIdx
while (i < messages.length) { while (i < messages.length) {
const message = messages[i] const message = messages[i]
if (message.role === 'assistant') { if (message.role === 'assistant') {
@@ -92,6 +109,12 @@ function renderTurns(
// assistant 组是「活跃回合」。透传给历史回合会让它们误显示 // assistant 组是「活跃回合」。透传给历史回合会让它们误显示
// 「已工作/处理中」并保持过程区展开。 // 「已工作/处理中」并保持过程区展开。
const isLive = j === messages.length const isLive = j === messages.length
// 本回合之后第一条真实用户消息(跳过压缩/摘要行):分叉锚点,'before' 含本回合答案为止
let nextUser = j
while (nextUser < messages.length && !(messages[nextUser].role === 'user' && !messages[nextUser].local)) {
nextUser += 1
}
const turnForkEntryId = nextUser < messages.length ? userForkIds.get(nextUser) : undefined
items.push( items.push(
<AssistantTurn <AssistantTurn
key={`${message.key}-turn`} key={`${message.key}-turn`}
@@ -101,6 +124,10 @@ function renderTurns(
turnActive={turnActive && isLive} turnActive={turnActive && isLive}
pendingApprovals={pendingApprovals} pendingApprovals={pendingApprovals}
scrollPinnedRef={pinnedRef} scrollPinnedRef={pinnedRef}
forking={forking}
onFork={isLive ? onFork : undefined}
forkEntryId={turnForkEntryId}
onForkAt={onForkAt}
/> />
) )
i = j i = j
@@ -154,8 +181,9 @@ export function ChatView({
onAbortCompaction, onAbortCompaction,
onRewind, onRewind,
onFork, onFork,
onForkAt,
onPickSuggestion, onPickSuggestion,
onApplySelection onQuoteSelection
}: { }: {
messages: UiMessage[] messages: UiMessage[]
tools: Record<string, ToolState> tools: Record<string, ToolState>
@@ -180,8 +208,10 @@ export function ChatView({
onRewind: (entryId: string, summarize: boolean) => void onRewind: (entryId: string, summarize: boolean) => void
/** 在会话末尾分叉出新会话(原会话保留) */ /** 在会话末尾分叉出新会话(原会话保留) */
onFork: () => void onFork: () => void
/** 从指定锚点分叉出新会话(含该锚点前的整个回合) */
onForkAt: (entryId: string) => void
onPickSuggestion: (text: string) => void onPickSuggestion: (text: string) => void
onApplySelection: (instruction: string, selection: string) => void onQuoteSelection: (selection: string) => void
}): ReactNode { }): ReactNode {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const pinnedRef = useRef(true) const pinnedRef = useRef(true)
@@ -260,6 +290,8 @@ export function ChatView({
}, [compactionNotice, onDismissCompactionNotice]) }, [compactionNotice, onDismissCompactionNotice])
const hasContent = messages.length > 0 const hasContent = messages.length > 0
// 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起
const canFork = !turnActive && !streaming && !compacting && !navigating
return ( return (
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col">
@@ -378,26 +410,10 @@ export function ChatView({
navigating, navigating,
onRewind, onRewind,
countUserMessages(messages, 0, hidden), countUserMessages(messages, 0, hidden),
hidden === 0 hidden === 0,
)} forking,
{/* 会话结尾:从此处分叉为新会话(pi fork,原会话保留)。回合进行中不打断,收起入口 */} canFork ? onFork : undefined,
{hasContent && !turnActive && !streaming && !compacting && !navigating && ( canFork ? onForkAt : undefined
<div className="mt-1 flex">
<button
type="button"
disabled={forking}
onClick={onFork}
title="复制当前会话到新的会话文件,从这里继续探索(原会话保留不动)"
className="flex h-7 items-center gap-1.5 rounded-control px-2 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2 disabled:cursor-default disabled:opacity-60"
>
{forking ? (
<LuLoaderCircle size={12} className="animate-spin" />
) : (
<LuGitFork size={12} className="rotate-180" />
)}
{forking ? '正在创建分支…' : '从此处分叉为新会话'}
</button>
</div>
)} )}
</div> </div>
) : ( ) : (
@@ -407,8 +423,8 @@ export function ChatView({
)} )}
</div> </div>
{/* 划选消息文本后浮出的操作条 */} {/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} /> <SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
</div> </div>
) )
} }
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { JSX, RefObject } from 'react' import type { JSX, RefObject } from 'react'
import { LuBrain, LuCheck, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu' import { LuBrain, LuCheck, LuChevronRight, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import type { import type {
AttachmentInfo, AttachmentInfo,
ChatResources, ChatResources,
@@ -123,7 +123,9 @@ export function Composer({
onCompact, onCompact,
onAbortCompaction, onAbortCompaction,
onToggleAutoCompaction, onToggleAutoCompaction,
prefillRef prefillRef,
pendingQuote = null,
onQuoteConsumed
}: { }: {
streaming: boolean streaming: boolean
/** 排队消息(steering 先于 followUp 送达) */ /** 排队消息(steering 先于 followUp 送达) */
@@ -157,6 +159,10 @@ export function Composer({
onToggleAutoCompaction: (enabled: boolean) => void onToggleAutoCompaction: (enabled: boolean) => void
/** 供空状态建议词点击后填入 */ /** 供空状态建议词点击后填入 */
prefillRef: RefObject<string | null> prefillRef: RefObject<string | null>
/** 划选「引用」注入的内容(blockquote);注入到输入框开头,保留已有草稿 */
pendingQuote?: string | null
/** 注入完成后由 Composer 通知上层清空 */
onQuoteConsumed?: () => void
}): JSX.Element { }): JSX.Element {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [modelOpen, setModelOpen] = useState(false) const [modelOpen, setModelOpen] = useState(false)
@@ -437,6 +443,19 @@ export function Composer({
} }
}, [prefillRef]) }, [prefillRef])
// 划选「引用」→ 注入输入框开头(已有草稿顺延到引用块之后),光标落在引用块末尾
useEffect(() => {
if (!pendingQuote) return
setValue((current) => (current.trim() ? `${pendingQuote}${current}` : pendingQuote))
onQuoteConsumed?.()
const el = textareaRef.current
if (el) {
el.focus()
const pos = pendingQuote.length
el.setSelectionRange(pos, pos)
}
}, [pendingQuote, onQuoteConsumed])
// 草稿实时保存(发送后输入框清空,草稿随之清除) // 草稿实时保存(发送后输入框清空,草稿随之清除)
useEffect(() => { useEffect(() => {
saveDraft(value) saveDraft(value)
@@ -467,6 +486,9 @@ export function Composer({
const permBtnRef = useRef<HTMLButtonElement>(null) const permBtnRef = useRef<HTMLButtonElement>(null)
const usageBtnRef = useRef<HTMLButtonElement>(null) const usageBtnRef = useRef<HTMLButtonElement>(null)
const thinkingBtnRef = useRef<HTMLButtonElement>(null) const thinkingBtnRef = useRef<HTMLButtonElement>(null)
/** 模型菜单:悬浮的 provider 行及其行内 rect;模型子列表悬浮其左侧(null = 未悬浮) */
const [hoveredProvider, setHoveredProvider] = useState<{ id: string; rect: DOMRect } | null>(null)
const hoverGraceRef = useRef<number | null>(null)
const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => { const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
if (!el) return null if (!el) return null
@@ -480,6 +502,7 @@ export function Composer({
return return
} }
setModelPos(anchorOf(modelBtnRef.current)) setModelPos(anchorOf(modelBtnRef.current))
setHoveredProvider(null)
setModelOpen((open) => { setModelOpen((open) => {
if (!open && !catalogLoadedRef.current) { if (!open && !catalogLoadedRef.current) {
catalogLoadedRef.current = true catalogLoadedRef.current = true
@@ -650,6 +673,28 @@ export function Composer({
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看 // 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? [] const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
// 服务商行的悬浮跟进:离开行时有 120ms 宽限,让鼠标能跨过缝隙进入悬浮模型列表
const enterProvider = (id: string, el: HTMLElement): void => {
if (hoverGraceRef.current !== null) {
window.clearTimeout(hoverGraceRef.current)
hoverGraceRef.current = null
}
setHoveredProvider({ id, rect: el.getBoundingClientRect() })
}
const leaveProvider = (): void => {
if (hoverGraceRef.current !== null) window.clearTimeout(hoverGraceRef.current)
hoverGraceRef.current = window.setTimeout(() => {
hoverGraceRef.current = null
setHoveredProvider(null)
}, 120)
}
const keepProviderFlyout = (): void => {
if (hoverGraceRef.current !== null) {
window.clearTimeout(hoverGraceRef.current)
hoverGraceRef.current = null
}
}
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text) const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment) const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
@@ -845,7 +890,7 @@ export function Composer({
aria-label="添加附件" aria-label="添加附件"
onClick={addAttachments} onClick={addAttachments}
disabled={disabled || picking} disabled={disabled || picking}
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)" title="添加附件"
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40" className="flex size-7 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -862,7 +907,7 @@ export function Composer({
aria-expanded={usageOpen} aria-expanded={usageOpen}
aria-label="上下文用量" aria-label="上下文用量"
onClick={toggleUsageCard} onClick={toggleUsageCard}
title="上下文容量、分类用量与缓存命中率" title="上下文用量"
className="flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium tabular-nums text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink" className="flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium tabular-nums text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
> >
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} /> <LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
@@ -913,7 +958,7 @@ export function Composer({
aria-label={`权限模式:${permMeta.label}`} aria-label={`权限模式:${permMeta.label}`}
onClick={togglePermMenu} onClick={togglePermMenu}
disabled={disabled} disabled={disabled}
title={`权限模式:${permMeta.label}(${permMeta.desc})`} title={`权限模式:${permMeta.label}`}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2' permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
}`} }`}
@@ -1095,41 +1140,39 @@ export function Composer({
/> />
)} )}
{/* 模型选择菜单(portal,向上弹出) */} {/* 模型选择菜单(portal,向上弹出)。服务商并列独立成行;悬浮某行时
该服务商的模型以悬浮列表出现在左侧(级联子菜单,portal 到 body),
点击模型即切换 */}
{modelOpen && modelPos && ( {modelOpen && modelPos && (
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}> <ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
<div className="w-72"> <div className="w-60 p-1.5" data-model-menu>
<div className="max-h-80 overflow-y-auto p-1.5">
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>} {catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
{!catalogLoading && providers.length === 0 && ( {!catalogLoading && providers.length === 0 && (
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div> <div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
)} )}
{providers.map((provider) => ( {providers.map((p) => {
<div key={provider.id}> const inUse = model?.provider === p.id
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-medium uppercase tracking-wide text-ink-3"> const hovered = hoveredProvider?.id === p.id
{provider.name}
</div>
{provider.models.map((m) => {
const selected = model?.provider === provider.id && model?.id === m.id
return ( return (
<button <div
key={`${provider.id}/${m.id}`} key={p.id}
type="button" onMouseEnter={(e) => enterProvider(p.id, e.currentTarget)}
onClick={() => { onMouseLeave={leaveProvider}
onSetModel(provider.id, m.id)
setModelOpen(false)
}}
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
> >
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span> <button
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />} type="button"
className={`flex w-full items-center gap-2 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover ${
hovered ? 'bg-hover' : ''
}`}
>
<span className="min-w-0 flex-1 truncate text-[13px] text-ink">{p.name}</span>
{inUse && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
<LuChevronRight size={12} className="shrink-0 text-ink-3" />
</button> </button>
</div>
) )
})} })}
</div> <div className="mt-1 border-t border-line pt-1">
))}
</div>
<div className="border-t border-line p-1.5">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1144,6 +1187,50 @@ export function Composer({
</div> </div>
</ComposerMenu> </ComposerMenu>
)} )}
{/* 悬浮模型列表:悬浮服务商行时出现在其左侧(portal 到 body,
ComposerMenu 的 overflow-hidden 会裁剪子元素) */}
{modelOpen &&
hoveredProvider &&
(() => {
const p = providers.find((x) => x.id === hoveredProvider.id)
if (!p) return null
return createPortal(
<div
data-model-flyout
className="fixed z-40 w-64 overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
style={{
right: window.innerWidth - hoveredProvider.rect.left + 8,
bottom: window.innerHeight - hoveredProvider.rect.bottom,
maxHeight: 336
}}
onMouseEnter={keepProviderFlyout}
onMouseLeave={leaveProvider}
>
<div className="max-h-[330px] overflow-y-auto p-1.5">
{p.models.map((m) => {
const selected = model?.provider === p.id && model?.id === m.id
return (
<button
key={`${p.id}/${m.id}`}
type="button"
onClick={() => {
onSetModel(p.id, m.id)
setModelOpen(false)
setHoveredProvider(null)
}}
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
</button>
)
})}
</div>
</div>,
document.body
)
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import { memo, useEffect, useState, type ReactNode } from 'react' import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown' import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkCjkFriendly from 'remark-cjk-friendly' import remarkCjkFriendly from 'remark-cjk-friendly'
@@ -98,18 +98,86 @@ const LANG_ALIAS: Record<string, string> = {
plaintext: '' plaintext: ''
} }
/** 行内处理:反引号代码 span 内不动;连续 2+ 个 ~ 保留(删除线标记),单个 ~ 转义为字面量 */
function escapeSingleTildeInline(line: string): string {
let out = ''
let i = 0
while (i < line.length) {
const ch = line[i]
if (ch === '`' || ch === '~') {
let j = i
while (j < line.length && line[j] === ch) j++
const run = line.slice(i, j)
if (ch === '`') {
out += run
const rest = line.slice(j)
const close = rest.indexOf(run)
if (close >= 0) {
out += rest.slice(0, close + run.length)
i = j + close + run.length
} else {
out += rest
i = line.length
}
continue
}
out += run.length >= 2 ? run : '\\~'
i = j
continue
}
out += ch
i++
}
return out
}
/** 单个 ~ 在 GFM 里会误触发删除线——中文常用 ~ 表区间(如「6强~7」「周~月」),
* 同段里出现两个就会被连成一条删除线。约定:只有成对的 ~~ 才是删除线,
* 不成对的单 ~ 转义为字面量;围栏代码块与行内代码内一律不动。 */
function neutralizeSingleTilde(text: string): string {
if (!text.includes('~')) return text
const lines = text.split('\n')
let fence: string | null = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (fence) {
const close = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null
continue
}
const open = line.match(/^\s{0,3}(`{3,}|~{3,})/)
if (open) {
fence = open[1]
continue
}
lines[i] = escapeSingleTildeInline(line)
}
return lines.join('\n')
}
const mdComponents: Components = { const mdComponents: Components = {
p({ children }) { p({ children }) {
return <p className="my-4 leading-normal">{children}</p> return <p className="my-4 leading-normal">{children}</p>
}, },
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
h1({ children }) { h1({ children }) {
return <h3 className="mb-2 mt-5 text-[17px] font-semibold text-ink first:mt-0">{children}</h3> return (
<h3 className="mt-5 mb-2 flex items-center gap-2 text-[18px] font-semibold text-ink first:mt-0">
<span aria-hidden className="h-[16px] w-[3px] shrink-0 rounded-full bg-accent" />
<span className="min-w-0">{children}</span>
</h3>
)
}, },
h2({ children }) { h2({ children }) {
return <h4 className="mb-2 mt-5 text-[15.5px] font-semibold text-ink first:mt-0">{children}</h4> return (
<h4 className="mt-5 mb-2 flex items-center gap-2 text-[17px] font-semibold text-ink first:mt-0">
<span aria-hidden className="h-[15px] w-[3px] shrink-0 rounded-full bg-accent" />
<span className="min-w-0">{children}</span>
</h4>
)
}, },
h3({ children }) { h3({ children }) {
return <h5 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h5> return <h5 className="mb-1.5 mt-4 text-[15px] font-semibold text-ink first:mt-0">{children}</h5>
}, },
h4({ children }) { h4({ children }) {
return <h6 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6> return <h6 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
@@ -275,10 +343,11 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
} }
function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode { function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
const safeText = useMemo(() => neutralizeSingleTilde(text), [text])
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown), // 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态 // 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
const { blockMatches } = useLLMOutput({ const { blockMatches } = useLLMOutput({
llmOutput: text, llmOutput: safeText,
isStreamFinished: !streaming, isStreamFinished: !streaming,
fallbackBlock: markdownFallback, fallbackBlock: markdownFallback,
blocks: [codeBlock] blocks: [codeBlock]
@@ -7,7 +7,7 @@ import { StreamingText } from './StreamingText'
import { Thinking, thinkingRows } from './Thinking' import { Thinking, thinkingRows } from './Thinking'
import { ToolChips, type ToolCallView } from './ToolChips' import { ToolChips, type ToolCallView } from './ToolChips'
import { useEntrance } from '../hooks/useEntrance' import { useEntrance } from '../hooks/useEntrance'
import { LuFoldVertical, LuHistory, LuUndo2 } from 'react-icons/lu' import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuUndo2 } from 'react-icons/lu'
import { formatTokens } from '../lib/utils' import { formatTokens } from '../lib/utils'
interface ContentBlock { interface ContentBlock {
@@ -129,7 +129,11 @@ function AssistantTurnBase({
streaming, streaming,
turnActive, turnActive,
pendingApprovals, pendingApprovals,
scrollPinnedRef scrollPinnedRef,
forking = false,
onFork,
forkEntryId,
onForkAt
}: { }: {
messages: UiMessage[] messages: UiMessage[]
tools: Record<string, ToolState> tools: Record<string, ToolState>
@@ -139,6 +143,14 @@ function AssistantTurnBase({
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部 /** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */ * 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
scrollPinnedRef?: { current: boolean } scrollPinnedRef?: { current: boolean }
/** 分叉进行中 */
forking?: boolean
/** 会话末尾分叉(只有最后一个回合透传) */
onFork?: () => void
/** 历史回合的分叉锚点:本回合之后第一条用户消息的 entryId
* (配合 onForkAt 以 'before' 分叉,新会话包含到本回合答案为止) */
forkEntryId?: string
onForkAt?: (entryId: string) => void
}): ReactNode { }): ReactNode {
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ---- // ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行 // 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
@@ -424,10 +436,31 @@ function AssistantTurnBase({
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */} {/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />} {turnActive && !stream && <Loading label="处理中" variant="Dots" />}
{/* 结论底部的复制操作:常驻不闪现 */} {/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
{(hasAnswer || answerText.trim().length > 0) && ( 历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
<div className="mt-1.5 flex"> 新会话都包含到本回合答案为止。常驻不闪现。 */}
{(hasAnswer || answerText.trim().length > 0 || onFork || (forkEntryId && onForkAt)) && (
<div className="mt-1.5 flex items-center gap-0.5">
<CopyAnswerButton text={answerText} /> <CopyAnswerButton text={answerText} />
{(onFork || (forkEntryId && onForkAt)) && (
<button
type="button"
aria-label="从此处分叉为新会话"
title="从此处分叉为新会话"
disabled={forking}
onClick={() => {
if (onFork) onFork()
else if (forkEntryId && onForkAt) onForkAt(forkEntryId)
}}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2 disabled:cursor-default disabled:opacity-40"
>
{forking ? (
<LuLoaderCircle size={15} className="animate-spin" />
) : (
<LuGitFork size={15} className="rotate-180" />
)}
</button>
)}
</div> </div>
)} )}
</div> </div>
@@ -443,7 +476,7 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
<button <button
type="button" type="button"
aria-label="回溯到此处" aria-label="回溯到此处"
title="回溯到这条消息(会话树上此节点之后的分叉将被离开)" title="回溯到这条消息"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2" className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
> >
@@ -636,6 +669,8 @@ function sameTurnProps(
next: ComponentProps<typeof AssistantTurnBase> next: ComponentProps<typeof AssistantTurnBase>
): boolean { ): boolean {
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
if (prev.forking !== next.forking || prev.onFork !== next.onFork) return false
if (prev.forkEntryId !== next.forkEntryId || prev.onForkAt !== next.onForkAt) return false
if (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false if (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false
if (!sameMessages(prev.messages, next.messages)) return false if (!sameMessages(prev.messages, next.messages)) return false
if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, next.messages)) return false if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, next.messages)) return false
@@ -1,43 +1,30 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react' import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu' import { LuCheck, LuCopy, LuQuote } from 'react-icons/lu'
/** 选区操作条(beautifului.dev Selection Actions)。 /** 选区操作条。
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条: * 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法; * 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */ * Esc / 滚动 / 点击后收起。 */
const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
{ label: '解释', icon: <LuCircleHelp size={14} strokeWidth={1.8} /> },
{ label: '改进', icon: <LuWandSparkles size={14} strokeWidth={1.8} /> }
]
const PRESET_MORE: { label: string; icon: ReactNode }[] = [
{ label: '精简', icon: <LuScissors size={14} strokeWidth={1.8} /> },
{ label: '语气', icon: <LuSmile size={14} strokeWidth={1.8} /> },
{ label: '语法', icon: <LuSpellCheck size={14} strokeWidth={1.8} /> }
]
const pillButton = const pillButton =
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0' 'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
export function SelectionActions({ export function SelectionActions({
containerRef, containerRef,
onApply onQuote
}: { }: {
containerRef: RefObject<HTMLElement | null> containerRef: RefObject<HTMLElement | null>
onApply: (instruction: string, selection: string) => void onQuote: (selection: string) => void
}): ReactNode { }): ReactNode {
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null) const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
const [selection, setSelection] = useState('') const [selection, setSelection] = useState('')
const [instruction, setInstruction] = useState('') const [copied, setCopied] = useState(false)
const [moreOpen, setMoreOpen] = useState(false)
const pillRef = useRef<HTMLDivElement>(null) const pillRef = useRef<HTMLDivElement>(null)
const dismiss = (): void => { const dismiss = (): void => {
setAnchor(null) setAnchor(null)
setSelection('') setSelection('')
setInstruction('') setCopied(false)
setMoreOpen(false)
} }
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起) // 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
@@ -94,19 +81,19 @@ export function SelectionActions({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchor]) }, [anchor])
const apply = (label: string): void => { const quote = (): void => {
if (!selection) return if (!selection) return
onApply(label, selection) onQuote(selection)
dismiss() dismiss()
window.getSelection()?.removeAllRanges() window.getSelection()?.removeAllRanges()
} }
const submitInstruction = (): void => { const copy = (): void => {
const text = instruction.trim() if (!selection) return
if (!text || !selection) return void navigator.clipboard.writeText(selection).then(() => {
onApply(`${text}(针对以下选中内容)`, selection) setCopied(true)
dismiss() window.setTimeout(dismiss, 900)
window.getSelection()?.removeAllRanges() })
} }
const visible = anchor !== null const visible = anchor !== null
@@ -122,82 +109,21 @@ export function SelectionActions({
pointerEvents: visible ? undefined : 'none' pointerEvents: visible ? undefined : 'none'
}} }}
> >
<div className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay"> <div className="flex h-9 w-fit items-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 shadow-overlay">
{/* 描述修改输入框(常显,站点默认态) */} <button type="button" className={pillButton} onClick={quote}>
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}> <LuQuote size={14} strokeWidth={1.8} />
<form 引用
className="flex h-7 shrink-0 items-center"
style={{ width: 152 }}
onSubmit={(e) => {
e.preventDefault()
submitInstruction()
}}
>
<input
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder="描述修改…"
aria-label="描述修改"
className="h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
/>
</form>
</div>
{/* 分隔线 */}
<span className="mx-1 h-4 w-px shrink-0 bg-line-strong" />
{/* 预设动作 + 更多 */}
<div className="flex min-w-0 items-center gap-0.5 overflow-hidden">
{PRESET_MAIN.map((action) => (
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
{action.icon}
{action.label}
</button> </button>
))}
<div
className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity] duration-[400ms]"
style={{ maxWidth: moreOpen ? 224 : 0, opacity: moreOpen ? 1 : 0, transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
>
{PRESET_MORE.map((action) => (
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
{action.icon}
{action.label}
</button>
))}
</div>
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" /> <span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
<button <button type="button" className={pillButton} onClick={copy}>
type="button" {copied ? (
aria-label="更多操作" <LuCheck size={14} strokeWidth={2} className="text-accent-ink" />
aria-expanded={moreOpen} ) : (
onClick={() => setMoreOpen((v) => !v)} <LuCopy size={14} strokeWidth={1.8} />
className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]" )}
> {copied ? '已复制' : '复制'}
<span className="flex transition-transform duration-[400ms]" style={{ transform: moreOpen ? 'rotate(180deg)' : 'rotate(0deg)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}>
<svg width="14" height="14" strokeWidth="1.8" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M9 6L15 12L9 18" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button> </button>
</div> </div>
{/* 发送钮:输入内容后出现 */}
<div
className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-[400ms]"
style={{ maxWidth: instruction.trim() ? 28 : 0, opacity: instruction.trim() ? 1 : 0, transform: instruction.trim() ? 'scale(1)' : 'scale(0.88)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
>
<button
type="button"
aria-label="发送修改指令"
onClick={submitInstruction}
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]"
>
<svg width="16" height="16" viewBox="0 0 24 24" strokeWidth="2.4" fill="none" aria-hidden>
<path d="M12 21L12 3M12 3L20.5 11.5M12 3L3.5 11.5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
</div> </div>
) )
} }
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { JSX, RefObject } from 'react' import type { JSX, RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { import {
LuBot,
LuBrain,
LuCheck, LuCheck,
LuCpu, LuCpu,
LuFolderOpen, LuFolderOpen,
@@ -12,29 +10,17 @@ import {
LuPlus, LuPlus,
LuPuzzle, LuPuzzle,
LuSearch, LuSearch,
LuServer,
LuSettings2, LuSettings2,
LuSparkles, LuSparkles,
LuTerminal, LuTerminal,
LuTextQuote, LuTextQuote,
LuWebhook,
LuX LuX
} from 'react-icons/lu' } from 'react-icons/lu'
import type { IconType } from 'react-icons' import type { IconType } from 'react-icons'
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types' import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types'
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme' import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
type Tab = type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands'
| 'general'
| 'model'
| 'prompt'
| 'memory'
| 'subagent'
| 'plugins'
| 'mcp'
| 'skills'
| 'commands'
| 'hooks'
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */ /** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
@@ -49,36 +35,24 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I
{ {
group: 'Agent 能力', group: 'Agent 能力',
items: [ items: [
{ id: 'memory', label: '记忆', icon: LuBrain },
{ id: 'subagent', label: '子智能体', icon: LuBot },
{ id: 'plugins', label: '插件', icon: LuPuzzle }, { id: 'plugins', label: '插件', icon: LuPuzzle },
{ id: 'mcp', label: 'MCP 服务器', icon: LuServer },
{ id: 'skills', label: '技能', icon: LuSparkles }, { id: 'skills', label: '技能', icon: LuSparkles },
{ id: 'commands', label: '命令', icon: LuTerminal }, { id: 'commands', label: '命令', icon: LuTerminal }
{ id: 'hooks', label: '钩子', icon: LuWebhook }
] ]
} }
] ]
const PLACEHOLDER: Record<string, string> = { const PLACEHOLDER: Record<string, string> = {
memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。', plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。'
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
} }
const TAB_META: Record<Tab, { title: string; desc: string }> = { const TAB_META: Record<Tab, { title: string; desc: string }> = {
general: { title: '常规', desc: '主题色等外观偏好。' }, general: { title: '常规', desc: '主题色等外观偏好。' },
model: { title: '模型设置', desc: '' }, model: { title: '模型设置', desc: '' },
prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' }, prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' },
memory: { title: '记忆', desc: PLACEHOLDER.memory },
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
plugins: { title: '插件', desc: PLACEHOLDER.plugins }, plugins: { title: '插件', desc: PLACEHOLDER.plugins },
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' }, skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }, commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
} }
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element { function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
@@ -131,50 +105,60 @@ function ResourcePanel({
}: { }: {
loading: boolean loading: boolean
items: ResourceItem[] items: ResourceItem[]
dirs: { user: string; project: string } /** 当前作用域的资源目录(可多个,如技能的 ~/.pi/agent/skills 与 ~/.agents/skills) */
dirs: { user: string[]; project: string[] }
emptyTitle: string emptyTitle: string
hints: { user: string; project: string } /** 与 dirs 一一对应的短标签(展示用;完整路径放 tooltip) */
hints: { user: string[]; project: string[] }
onOpen: (path: string) => void onOpen: (path: string) => void
onOpenDir: (path: string) => void onOpenDir: (path: string) => void
}): ReactNode { }): ReactNode {
const [scope, setScope] = useState<'user' | 'project'>('user') const [scope, setScope] = useState<'user' | 'project'>('user')
const scoped = items.filter((i) => i.scope === scope) const scoped = items.filter((i) => i.scope === scope)
const dir = scope === 'user' ? dirs.user : dirs.project const scopedDirs = (scope === 'user' ? dirs.user : dirs.project).filter(Boolean)
const scopedHints = scope === 'user' ? hints.user : hints.project
return ( return (
<div className="flex min-h-0 flex-col"> <div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 pb-3"> <div className="flex items-center gap-2 pb-3">
<div className="flex items-center rounded-control bg-hover p-0.5"> <div className="flex items-center rounded-control bg-zinc-100 p-0.5">
{(['user', 'project'] as const).map((s) => ( {(['user', 'project'] as const).map((s) => (
<button <button
key={s} key={s}
type="button" type="button"
onClick={() => setScope(s)} onClick={() => setScope(s)}
className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${ className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${
scope === s ? 'bg-surface font-medium text-ink shadow-sm' : 'text-ink-3 hover:text-ink-2' scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
}`} }`}
> >
{s === 'user' ? '全局' : '项目'} {s === 'user' ? '全局' : '项目'}
</button> </button>
))} ))}
</div> </div>
{dir && ( <div className="ml-auto flex shrink-0 items-center gap-1">
{scopedDirs.map((d, i) => (
<button <button
key={d || i}
type="button" type="button"
title={dir} title={d}
onClick={() => onOpenDir(dir)} onClick={() => onOpenDir(d)}
className="ml-auto flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2" className="flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 font-mono text-[10.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
> >
<LuFolderOpen size={12} className="shrink-0" /> <LuFolderOpen size={12} className="shrink-0" />
打开目录 {scopedHints[i] ?? '打开目录'}
</button> </button>
)} ))}
</div>
</div> </div>
{!loading && scoped.length === 0 ? ( {!loading && scoped.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center"> <div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-zinc-200 py-12 text-center">
<div className="text-[13px] font-medium text-ink-2">{emptyTitle}</div> <div className="text-[13px] font-medium text-zinc-600">{emptyTitle}</div>
<div className="text-[12px] text-ink-3">{hints[scope]}</div> {scopedHints.map((h) => (
<div key={h} className="font-mono text-[11.5px] text-zinc-400">
{h}
</div>
))}
</div> </div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -394,7 +378,7 @@ function ModelSettings({
type="password" type="password"
value={keyValue} value={keyValue}
onChange={(e) => onKeyChange(e.target.value)} onChange={(e) => onKeyChange(e.target.value)}
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)" placeholder="输入 API Key"
className="flex-1 rounded-control border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none focus:border-ink-3" className="flex-1 rounded-control border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none focus:border-ink-3"
/> />
<button <button
@@ -910,11 +894,14 @@ export function SettingsModal({
filePath: s.filePath filePath: s.filePath
}))} }))}
dirs={{ dirs={{
user: resources?.dirs.globalSkills ?? '', user: [resources?.dirs.globalSkills ?? '', resources?.dirs.globalAgentsSkills ?? ''],
project: resources?.dirs.projectSkills ?? '' project: [resources?.dirs.projectSkills ?? '', resources?.dirs.projectAgentsSkills ?? '']
}} }}
emptyTitle="暂无技能" emptyTitle="暂无技能"
hints={{ user: '~/.pi/agent/skills', project: '工作区/.pi/skills' }} hints={{
user: ['~/.pi/agent/skills', '~/.agents/skills'],
project: ['工作区/.pi/skills', '工作区/.agents/skills']
}}
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)} onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
onOpenDir={(path) => { onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) => window.api.openDirectory(path).catch((err: unknown) =>
@@ -936,11 +923,11 @@ export function SettingsModal({
filePath: c.filePath filePath: c.filePath
}))} }))}
dirs={{ dirs={{
user: resources?.dirs.globalCommands ?? '', user: [resources?.dirs.globalCommands ?? ''],
project: resources?.dirs.projectCommands ?? '' project: [resources?.dirs.projectCommands ?? '']
}} }}
emptyTitle="暂无自定义命令" emptyTitle="暂无自定义命令"
hints={{ user: '~/.pi/agent/prompts', project: '工作区/.pi/prompts' }} hints={{ user: ['~/.pi/agent/prompts'], project: ['工作区/.pi/prompts'] }}
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)} onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
onOpenDir={(path) => { onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) => window.api.openDirectory(path).catch((err: unknown) =>
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { import {
LuCheck, LuCheck,
LuChevronDown, LuChevronDown,
LuMessagesSquare,
LuPencil, LuPencil,
LuPlus, LuPlus,
LuSearch, LuSearch,
@@ -78,6 +79,87 @@ function RowActionPortal({
) )
} }
/** 折叠态会话切换浮层:锚定 rail 图标向右弹出,类浏览器标签切换(仅切换,不管理)。
* portal 到 body(aside overflow-hidden 会裁剪内联浮层);点外部 / Esc / 选中即关。 */
function SessionSwitcherPortal({
anchor,
sessions,
activeSessionId,
onOpenSession,
onClose
}: {
anchor: { top: number; right: number }
sessions: SessionMeta[]
activeSessionId?: string
onOpenSession: (path: string) => void
onClose: () => void
}): ReactNode {
const closeRef = useRef(onClose)
closeRef.current = onClose
useEffect(() => {
const close = (event: PointerEvent): void => {
const target = event.target as Element
if (target.closest('[data-session-flyout]') || target.closest('[data-session-toggle]')) return
closeRef.current()
}
const esc = (event: KeyboardEvent): void => {
if (event.key === 'Escape') closeRef.current()
}
document.addEventListener('pointerdown', close)
document.addEventListener('keydown', esc)
return () => {
document.removeEventListener('pointerdown', close)
document.removeEventListener('keydown', esc)
}
}, [])
const top = Math.max(8, Math.min(anchor.top, window.innerHeight - 220))
const listMaxHeight = Math.max(160, window.innerHeight - top - 52)
return createPortal(
<div
data-session-flyout
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
style={{
left: anchor.right + 12,
top,
animation: 'pop-in 180ms cubic-bezier(0.23,1,0.32,1) both',
transformOrigin: 'top left'
}}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium text-ink-3">会话</div>
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{sessions.length === 0 ? (
<div className="px-3 py-6 text-center text-[12px] text-ink-3">暂无历史会话</div>
) : (
sessions.map((s) => {
const active = s.id === activeSessionId
return (
<button
key={s.id}
type="button"
title={sessionTitle(s)}
onClick={() => {
onOpenSession(s.path)
onClose()
}}
className={`flex h-8 w-full items-center gap-2 rounded-[7px] px-2 text-left transition-colors ${
active ? 'bg-hover-2 text-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className={`min-w-0 flex-1 truncate text-[13px] ${active ? 'font-medium' : 'font-normal'}`}>
{sessionTitle(s)}
</span>
{active && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
</button>
)
})
)}
</div>
</div>,
document.body
)
}
export function SidebarNav({ export function SidebarNav({
sessionState, sessionState,
sessions, sessions,
@@ -122,6 +204,8 @@ export function SidebarNav({
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [wsMenuOpen, setWsMenuOpen] = useState(false) const [wsMenuOpen, setWsMenuOpen] = useState(false)
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set()) const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
/** 折叠态会话切换浮层:锚点(rail 图标顶缘/右缘),null = 关闭 */
const [sessionSwitcher, setSessionSwitcher] = useState<{ top: number; right: number } | null>(null)
const renameInputRef = useRef<HTMLInputElement>(null) const renameInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
@@ -139,6 +223,7 @@ export function SidebarNav({
useEffect(() => { useEffect(() => {
if (collapsed) setWsMenuOpen(false) if (collapsed) setWsMenuOpen(false)
else setSessionSwitcher(null)
}, [collapsed]) }, [collapsed])
useEffect(() => { useEffect(() => {
@@ -340,7 +425,8 @@ export function SidebarNav({
{/* 会话列表区 */} {/* 会话列表区 */}
<div className="mt-3 min-h-0 flex-1 overflow-y-auto"> <div className="mt-3 min-h-0 flex-1 overflow-y-auto">
<div className="sidebar-copy relative mx-2 mb-1 h-8"> <div className="relative mx-2 mb-1 h-8">
<div className="sidebar-copy absolute inset-0">
<button <button
type="button" type="button"
onClick={() => setChatsOpen((v) => !v)} onClick={() => setChatsOpen((v) => !v)}
@@ -401,7 +487,24 @@ export function SidebarNav({
</div> </div>
</div> </div>
{chatsOpen && ( {/* 折叠态:会话切换入口(类浏览器标签切换,仅切换)。
收窄到 rail 可见的 36px(与 nav 图标钮同宽),否则图标会被 aside 裁剪 */}
<button
type="button"
data-session-toggle
aria-label="切换会话"
title="切换会话"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setSessionSwitcher((v) => (v ? null : { top: rect.top, right: rect.right }))
}}
className="sidebar-collapsed-only absolute left-0 top-0 h-8 w-9 items-center justify-center rounded-[8px] text-ink-2 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96] [-webkit-app-region:no-drag]"
>
<LuMessagesSquare size={18} strokeWidth={2} />
</button>
</div>
{chatsOpen && !collapsed && (
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]"> <GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3"> <div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
@@ -416,12 +519,23 @@ export function SidebarNav({
data-row data-row
title={sessionTitle(s)} title={sessionTitle(s)}
onClick={() => onOpenSession(s.path)} onClick={() => onOpenSession(s.path)}
className={`${navRowClass} group/row cursor-pointer ${active ? 'text-ink' : 'text-ink-2'}`} className={`${navRowClass} group/row cursor-pointer ${
active ? 'bg-hover-2 text-ink' : 'text-ink-2'
}`}
> >
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px */} {/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span> 激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */}
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */} <span
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex"> className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] ${
active ? 'font-medium' : 'font-normal'
}`}
>
{sessionTitle(s)}
</span>
{/* 悬停操作:quiet 图标钮,无底板。行内流式布局(非绝对定位):
悬停时标题自动收窄让位,按钮不越出行、不遮相邻行的文字;
收起侧栏时随 sidebar-copy 一并淡出并禁用点击 */}
<span className="sidebar-copy hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
{active && ( {active && (
<button <button
type="button" type="button"
@@ -463,6 +577,17 @@ export function SidebarNav({
)} )}
</div> </div>
{/* 折叠态会话切换浮层(portal 到 body) */}
{sessionSwitcher && (
<SessionSwitcherPortal
anchor={sessionSwitcher}
sessions={sessions}
activeSessionId={activeSessionId}
onOpenSession={onOpenSession}
onClose={() => setSessionSwitcher(null)}
/>
)}
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */} {/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
{actionMenu && ( {actionMenu && (
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}> <RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
@@ -1,9 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
/** 可展开的思考轨迹(ZCode 式实时预览)。 /** 可展开的思考轨迹。
* 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由 * 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
* 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。 * 再点收起;流式中表头标签保持 shimmer。
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填): * 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时 * 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明 * 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
@@ -22,22 +22,21 @@ export function Thinking({
activeLabel?: string activeLabel?: string
doneLabel?: string doneLabel?: string
}): ReactNode { }): ReactNode {
// 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准 // 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null) const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
const expanded = manualExpanded ?? true const expanded = manualExpanded ?? false
const latest = rows.length > 0 ? rows[rows.length - 1].primary : ''
const firstLine = rows.length > 0 ? rows[0].primary : '' const firstLine = rows.length > 0 ? rows[0].primary : ''
const statusWord = streaming ? activeLabel : expanded ? '思考' : doneLabel const statusWord = streaming ? activeLabel : doneLabel
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : '' const suffix = expanded ? '' : firstLine
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */} {/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
<button <button
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setManualExpanded((current) => !(current ?? true))} onClick={() => setManualExpanded((current) => !(current ?? false))}
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2" className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}> <svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
@@ -126,7 +126,7 @@ export function UsageCard({
role="switch" role="switch"
aria-checked={autoCompactionEnabled} aria-checked={autoCompactionEnabled}
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)} onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
title="上下文接近窗口上限时自动压缩历史(阈值/溢出触发)" title="接近窗口上限时自动压缩历史"
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink" className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
> >
<span <span
@@ -163,7 +163,7 @@ export function UsageCard({
type="button" type="button"
onClick={onCompact} onClick={onCompact}
disabled={total === null || total === 0} disabled={total === null || total === 0}
title="把较早的对话历史压缩为摘要,释放上下文空间(可附带自定义指令,见 /compact)" title="把较早的对话压缩为摘要,释放上下文空间"
className="flex h-7 items-center rounded-[7px] border border-line bg-surface px-2.5 text-[12px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:cursor-default disabled:opacity-40 disabled:hover:bg-surface" className="flex h-7 items-center rounded-[7px] border border-line bg-surface px-2.5 text-[12px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:cursor-default disabled:opacity-40 disabled:hover:bg-surface"
> >
立即压缩 立即压缩
@@ -373,6 +373,8 @@ export interface UseAgent {
navigateTree: (entryId: string, options?: NavigateTreeOptions) => void navigateTree: (entryId: string, options?: NavigateTreeOptions) => void
/** 在会话末尾分叉出新会话(pi fork,原会话保留) */ /** 在会话末尾分叉出新会话(pi fork,原会话保留) */
forkSession: () => void forkSession: () => void
/** 从锚点分叉:'at' 含该条为止 / 'before' 到其父条目为止 */
forkSessionAt: (entryId: string, position: 'at' | 'before') => void
} }
export function useAgent(): UseAgent { export function useAgent(): UseAgent {
@@ -580,6 +582,18 @@ export function useAgent(): UseAgent {
.finally(() => setForking(false)) .finally(() => setForking(false))
}, [dispatchError]) }, [dispatchError])
/** 从会话树上的锚点分叉出新会话('at' 含该条为止 / 'before' 到其父条目为止,即包含前面整个回合) */
const forkSessionAt = useCallback(
(entryId: string, position: 'at' | 'before'): void => {
setForking(true)
window.api
.forkSessionAt(entryId, position)
.catch(dispatchError)
.finally(() => setForking(false))
},
[dispatchError]
)
return useMemo( return useMemo(
() => ({ () => ({
state, state,
@@ -607,7 +621,8 @@ export function useAgent(): UseAgent {
setAutoCompaction, setAutoCompaction,
dismissCompactionNotice, dismissCompactionNotice,
navigateTree, navigateTree,
forkSession forkSession,
forkSessionAt
}), }),
[ [
state, state,
@@ -635,7 +650,8 @@ export function useAgent(): UseAgent {
setAutoCompaction, setAutoCompaction,
dismissCompactionNotice, dismissCompactionNotice,
navigateTree, navigateTree,
forkSession forkSession,
forkSessionAt
] ]
) )
} }
+4
View File
@@ -44,6 +44,9 @@ declare global {
minimize: () => Promise<void> minimize: () => Promise<void>
toggleMaximize: () => Promise<void> toggleMaximize: () => Promise<void>
close: () => Promise<void> close: () => Promise<void>
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
zoom: (factor: number) => void
zoomReset: () => void
onWindowState: (callback: (maximized: boolean) => void) => () => void onWindowState: (callback: (maximized: boolean) => void) => () => void
send: (text: string, images?: SendImage[]) => Promise<void> send: (text: string, images?: SendImage[]) => Promise<void>
steer: (text: string) => Promise<void> steer: (text: string) => Promise<void>
@@ -81,6 +84,7 @@ declare global {
listForkPoints: () => Promise<ForkPoint[]> listForkPoints: () => Promise<ForkPoint[]>
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void> navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
forkSession: () => Promise<void> forkSession: () => Promise<void>
forkSessionAt: (entryId: string, position: 'at' | 'before') => Promise<void>
getSettings: () => Promise<AppSettings> getSettings: () => Promise<AppSettings>
getSystemPrompt: () => Promise<string> getSystemPrompt: () => Promise<string>
setThemeColor: (id: string) => Promise<void> setThemeColor: (id: string) => Promise<void>
@@ -312,6 +312,15 @@ a:focus-visible .animated-underline::after {
width: 36px; width: 36px;
} }
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
/* ── shared keyframes ─────────────────────────────────── */ /* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text { @keyframes shimmer-text {
+8 -1
View File
@@ -209,7 +209,14 @@ export interface CommandDetail {
export interface ResourceDetails { export interface ResourceDetails {
skills: SkillDetail[] skills: SkillDetail[]
commands: CommandDetail[] commands: CommandDetail[]
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string } dirs: {
globalSkills: string
globalAgentsSkills: string
projectSkills: string
projectAgentsSkills: string
globalCommands: string
projectCommands: string
}
} }
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */ /** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */