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,原会话保留)
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())
+43 -34
View File
@@ -2,13 +2,13 @@ import { app, dialog, shell } from 'electron'
import { nativeTheme } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
estimateTokens,
getAgentDir,
loadSkills,
SessionManager,
type AgentSession,
type AgentSessionEvent,
@@ -476,7 +476,7 @@ export class AgentService {
]
const byKey = new Map(breakdown.map((b) => [b.key, b]))
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('skills')!.tokens = skillsTokens
@@ -522,15 +522,14 @@ export class AgentService {
}
}
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化) */
private estimateSkillsTokens(): number {
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。
* 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */
private estimateSkillsTokens(session: AgentSession): number {
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
let value = 0
try {
const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true })
value = Math.ceil(
loaded.skills.reduce((sum, s) => sum + s.name.length + (s.description?.length ?? 0) + 8, 0) / 4
)
const loaded = session.resourceLoader.getSkills()
value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4)
} catch {
value = 0
}
@@ -633,18 +632,11 @@ export class AgentService {
async listChatResources(): Promise<ChatResources> {
const runtime = await this.getRuntime()
let skills: ChatResources['skills'] = []
try {
const loaded = loadSkills({
cwd: this.cwd,
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)
}
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
name: s.name,
description: s.description
}))
const commands = runtime.session.promptTemplates.map((t) => ({
name: t.name,
description: t.description ?? '',
@@ -653,30 +645,31 @@ export class AgentService {
return { commands, skills }
}
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */
async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir()
const dirs = {
globalSkills: join(agentDir, 'skills'),
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
projectSkills: join(this.cwd, '.pi', 'skills'),
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts')
}
let skills: SkillDetail[] = []
const skills: SkillDetail[] = []
try {
const loaded = loadSkills({
cwd: this.cwd,
agentDir,
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({
const runtime = await this.getRuntime()
for (const s of runtime.session.resourceLoader.getSkills().skills) {
skills.push({
name: s.name,
description: s.description ?? '',
description: s.description,
filePath: s.filePath,
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
// 与 resource loader 的默认归类一致:无来源信息时按用户级
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
disableModelInvocation: Boolean(s.disableModelInvocation)
}))
})
}
} catch (err) {
console.error('[agent-service] load skill details failed:', err)
}
@@ -953,6 +946,12 @@ export class AgentService {
const mr = runtime.session.modelRuntime
const credentials = await mr.listCredentials()
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
.getProviders()
.map((p) => {
@@ -960,7 +959,7 @@ export class AgentService {
return {
id: 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`),
models: mr
.getModels(pid)
@@ -1076,7 +1075,17 @@ export class AgentService {
if (!leafId) {
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
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import type {
AgentUiEvent,
AppSettings,
@@ -28,6 +28,15 @@ const api = {
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
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) => {
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
ipcRenderer.on('win:state', listener)
@@ -78,6 +87,8 @@ const api = {
navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> =>
ipcRenderer.invoke('session:navigate', entryId, options),
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'),
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
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,
dismissCompactionNotice,
navigateTree,
forkSession
forkSession,
forkSessionAt
} = useAgent()
const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -146,7 +147,7 @@ export default function App(): JSX.Element {
return stopWatching
}, [])
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
@@ -158,6 +159,15 @@ export default function App(): JSX.Element {
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
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') {
setSettingsOpen(false)
setSearchOpen(false)
@@ -171,10 +181,12 @@ export default function App(): JSX.Element {
prefillRef.current = text
}
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
const handleApplySelection = (instruction: string, selection: string): void => {
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
}
// 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿)
const [pendingQuote, setPendingQuote] = useState<string | null>(null)
const handleQuoteSelection = useCallback((selection: string) => {
setPendingQuote(`> ${selection}\n\n`)
}, [])
const consumePendingQuote = useCallback(() => setPendingQuote(null), [])
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
const handleRewind = useCallback(
@@ -261,8 +273,9 @@ export default function App(): JSX.Element {
onAbortCompaction={abortCompaction}
onRewind={handleRewind}
onFork={forkSession}
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
onPickSuggestion={handlePickSuggestion}
onApplySelection={handleApplySelection}
onQuoteSelection={handleQuoteSelection}
/>
<BottomSlot
approvals={state.approvals}
@@ -292,6 +305,8 @@ export default function App(): JSX.Element {
onAbortCompaction={abortCompaction}
onToggleAutoCompaction={setAutoCompaction}
prefillRef={prefillRef}
pendingQuote={pendingQuote}
onQuoteConsumed={consumePendingQuote}
/>
}
/>
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } 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 { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent'
import { formatTokens } from '../lib/utils'
@@ -66,7 +66,10 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
* animate=false(正在补齐历史)时挂载的消息不播入场动画。 */
* animate=false(正在补齐历史)时挂载的消息不播入场动画。
* 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合;
* 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before')
* ——新会话包含到本回合答案为止。 */
function renderTurns(
messages: UiMessage[],
tools: Record<string, ToolState>,
@@ -78,11 +81,25 @@ function renderTurns(
rewinding: boolean,
onRewind: (entryId: string, summarize: boolean) => void,
startUserIdx = 0,
animate = true
animate = true,
forking = false,
onFork?: () => void,
onForkAt?: (entryId: string) => void
): ReactNode[] {
const items: ReactNode[] = []
let i = 0
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) {
const message = messages[i]
if (message.role === 'assistant') {
@@ -92,6 +109,12 @@ function renderTurns(
// assistant 组是「活跃回合」。透传给历史回合会让它们误显示
// 「已工作/处理中」并保持过程区展开。
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(
<AssistantTurn
key={`${message.key}-turn`}
@@ -101,6 +124,10 @@ function renderTurns(
turnActive={turnActive && isLive}
pendingApprovals={pendingApprovals}
scrollPinnedRef={pinnedRef}
forking={forking}
onFork={isLive ? onFork : undefined}
forkEntryId={turnForkEntryId}
onForkAt={onForkAt}
/>
)
i = j
@@ -154,8 +181,9 @@ export function ChatView({
onAbortCompaction,
onRewind,
onFork,
onForkAt,
onPickSuggestion,
onApplySelection
onQuoteSelection
}: {
messages: UiMessage[]
tools: Record<string, ToolState>
@@ -180,8 +208,10 @@ export function ChatView({
onRewind: (entryId: string, summarize: boolean) => void
/** 在会话末尾分叉出新会话(原会话保留) */
onFork: () => void
/** 从指定锚点分叉出新会话(含该锚点前的整个回合) */
onForkAt: (entryId: string) => void
onPickSuggestion: (text: string) => void
onApplySelection: (instruction: string, selection: string) => void
onQuoteSelection: (selection: string) => void
}): ReactNode {
const scrollRef = useRef<HTMLDivElement>(null)
const pinnedRef = useRef(true)
@@ -260,6 +290,8 @@ export function ChatView({
}, [compactionNotice, onDismissCompactionNotice])
const hasContent = messages.length > 0
// 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起
const canFork = !turnActive && !streaming && !compacting && !navigating
return (
<div className="relative flex min-h-0 flex-1 flex-col">
@@ -378,26 +410,10 @@ export function ChatView({
navigating,
onRewind,
countUserMessages(messages, 0, hidden),
hidden === 0
)}
{/* 会话结尾:从此处分叉为新会话(pi fork,原会话保留)。回合进行中不打断,收起入口 */}
{hasContent && !turnActive && !streaming && !compacting && !navigating && (
<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>
hidden === 0,
forking,
canFork ? onFork : undefined,
canFork ? onForkAt : undefined
)}
</div>
) : (
@@ -407,8 +423,8 @@ export function ChatView({
)}
</div>
{/* 划选消息文本后浮出的操作条 */}
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
{/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
<SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
</div>
)
}
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
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 {
AttachmentInfo,
ChatResources,
@@ -123,7 +123,9 @@ export function Composer({
onCompact,
onAbortCompaction,
onToggleAutoCompaction,
prefillRef
prefillRef,
pendingQuote = null,
onQuoteConsumed
}: {
streaming: boolean
/** 排队消息(steering 先于 followUp 送达) */
@@ -157,6 +159,10 @@ export function Composer({
onToggleAutoCompaction: (enabled: boolean) => void
/** 供空状态建议词点击后填入 */
prefillRef: RefObject<string | null>
/** 划选「引用」注入的内容(blockquote);注入到输入框开头,保留已有草稿 */
pendingQuote?: string | null
/** 注入完成后由 Composer 通知上层清空 */
onQuoteConsumed?: () => void
}): JSX.Element {
const [value, setValue] = useState('')
const [modelOpen, setModelOpen] = useState(false)
@@ -437,6 +443,19 @@ export function Composer({
}
}, [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(() => {
saveDraft(value)
@@ -467,6 +486,9 @@ export function Composer({
const permBtnRef = useRef<HTMLButtonElement>(null)
const usageBtnRef = 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 => {
if (!el) return null
@@ -480,6 +502,7 @@ export function Composer({
return
}
setModelPos(anchorOf(modelBtnRef.current))
setHoveredProvider(null)
setModelOpen((open) => {
if (!open && !catalogLoadedRef.current) {
catalogLoadedRef.current = true
@@ -650,6 +673,28 @@ export function Composer({
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
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 canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
@@ -845,7 +890,7 @@ export function Composer({
aria-label="添加附件"
onClick={addAttachments}
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"
>
<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-label="上下文用量"
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"
>
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
@@ -913,7 +958,7 @@ export function Composer({
aria-label={`权限模式:${permMeta.label}`}
onClick={togglePermMenu}
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 ${
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 && (
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
<div className="w-72">
<div className="max-h-80 overflow-y-auto p-1.5">
<div className="w-60 p-1.5" data-model-menu>
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
{!catalogLoading && providers.length === 0 && (
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
)}
{providers.map((provider) => (
<div key={provider.id}>
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">
{provider.name}
</div>
{provider.models.map((m) => {
const selected = model?.provider === provider.id && model?.id === m.id
{providers.map((p) => {
const inUse = model?.provider === p.id
const hovered = hoveredProvider?.id === p.id
return (
<button
key={`${provider.id}/${m.id}`}
type="button"
onClick={() => {
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"
<div
key={p.id}
onMouseEnter={(e) => enterProvider(p.id, e.currentTarget)}
onMouseLeave={leaveProvider}
>
<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
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>
</div>
)
})}
</div>
))}
</div>
<div className="border-t border-line p-1.5">
<div className="mt-1 border-t border-line pt-1">
<button
type="button"
onClick={() => {
@@ -1144,6 +1187,50 @@ export function Composer({
</div>
</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>
@@ -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 remarkGfm from 'remark-gfm'
import remarkCjkFriendly from 'remark-cjk-friendly'
@@ -98,18 +98,86 @@ const LANG_ALIAS: Record<string, string> = {
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 = {
p({ children }) {
return <p className="my-4 leading-normal">{children}</p>
},
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
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 }) {
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 }) {
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 }) {
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 {
const safeText = useMemo(() => neutralizeSingleTilde(text), [text])
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
const { blockMatches } = useLLMOutput({
llmOutput: text,
llmOutput: safeText,
isStreamFinished: !streaming,
fallbackBlock: markdownFallback,
blocks: [codeBlock]
@@ -7,7 +7,7 @@ import { StreamingText } from './StreamingText'
import { Thinking, thinkingRows } from './Thinking'
import { ToolChips, type ToolCallView } from './ToolChips'
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'
interface ContentBlock {
@@ -129,7 +129,11 @@ function AssistantTurnBase({
streaming,
turnActive,
pendingApprovals,
scrollPinnedRef
scrollPinnedRef,
forking = false,
onFork,
forkEntryId,
onForkAt
}: {
messages: UiMessage[]
tools: Record<string, ToolState>
@@ -139,6 +143,14 @@ function AssistantTurnBase({
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
scrollPinnedRef?: { current: boolean }
/** 分叉进行中 */
forking?: boolean
/** 会话末尾分叉(只有最后一个回合透传) */
onFork?: () => void
/** 历史回合的分叉锚点:本回合之后第一条用户消息的 entryId
* (配合 onForkAt 以 'before' 分叉,新会话包含到本回合答案为止) */
forkEntryId?: string
onForkAt?: (entryId: string) => void
}): ReactNode {
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
@@ -424,10 +436,31 @@ function AssistantTurnBase({
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
{/* 结论底部的复制操作:常驻不闪现 */}
{(hasAnswer || answerText.trim().length > 0) && (
<div className="mt-1.5 flex">
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
新会话都包含到本回合答案为止。常驻不闪现。 */}
{(hasAnswer || answerText.trim().length > 0 || onFork || (forkEntryId && onForkAt)) && (
<div className="mt-1.5 flex items-center gap-0.5">
<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>
@@ -443,7 +476,7 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
<button
type="button"
aria-label="回溯到此处"
title="回溯到这条消息(会话树上此节点之后的分叉将被离开)"
title="回溯到这条消息"
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"
>
@@ -636,6 +669,8 @@ function sameTurnProps(
next: ComponentProps<typeof AssistantTurnBase>
): boolean {
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 (!sameMessages(prev.messages, 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 { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
import { LuCheck, LuCopy, LuQuote } from 'react-icons/lu'
/** 选区操作条(beautifului.dev Selection Actions)。
/** 选区操作条。
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
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} /> }
]
* 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
* Esc / 滚动 / 点击后收起。 */
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'
export function SelectionActions({
containerRef,
onApply
onQuote
}: {
containerRef: RefObject<HTMLElement | null>
onApply: (instruction: string, selection: string) => void
onQuote: (selection: string) => void
}): ReactNode {
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
const [selection, setSelection] = useState('')
const [instruction, setInstruction] = useState('')
const [moreOpen, setMoreOpen] = useState(false)
const [copied, setCopied] = useState(false)
const pillRef = useRef<HTMLDivElement>(null)
const dismiss = (): void => {
setAnchor(null)
setSelection('')
setInstruction('')
setMoreOpen(false)
setCopied(false)
}
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
@@ -94,19 +81,19 @@ export function SelectionActions({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchor])
const apply = (label: string): void => {
const quote = (): void => {
if (!selection) return
onApply(label, selection)
onQuote(selection)
dismiss()
window.getSelection()?.removeAllRanges()
}
const submitInstruction = (): void => {
const text = instruction.trim()
if (!text || !selection) return
onApply(`${text}(针对以下选中内容)`, selection)
dismiss()
window.getSelection()?.removeAllRanges()
const copy = (): void => {
if (!selection) return
void navigator.clipboard.writeText(selection).then(() => {
setCopied(true)
window.setTimeout(dismiss, 900)
})
}
const visible = anchor !== null
@@ -122,82 +109,21 @@ export function SelectionActions({
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 w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
<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}
<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}>
<LuQuote size={14} strokeWidth={1.8} />
引用
</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" />
<button
type="button"
aria-label="更多操作"
aria-expanded={moreOpen}
onClick={() => setMoreOpen((v) => !v)}
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]"
>
<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 type="button" className={pillButton} onClick={copy}>
{copied ? (
<LuCheck size={14} strokeWidth={2} className="text-accent-ink" />
) : (
<LuCopy size={14} strokeWidth={1.8} />
)}
{copied ? '已复制' : '复制'}
</button>
</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>
)
}
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { JSX, RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
LuBot,
LuBrain,
LuCheck,
LuCpu,
LuFolderOpen,
@@ -12,29 +10,17 @@ import {
LuPlus,
LuPuzzle,
LuSearch,
LuServer,
LuSettings2,
LuSparkles,
LuTerminal,
LuTextQuote,
LuWebhook,
LuX
} from 'react-icons/lu'
import type { IconType } from 'react-icons'
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types'
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
type Tab =
| 'general'
| 'model'
| 'prompt'
| 'memory'
| 'subagent'
| 'plugins'
| 'mcp'
| 'skills'
| 'commands'
| 'hooks'
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands'
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
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 能力',
items: [
{ id: 'memory', label: '记忆', icon: LuBrain },
{ id: 'subagent', label: '子智能体', icon: LuBot },
{ id: 'plugins', label: '插件', icon: LuPuzzle },
{ id: 'mcp', label: 'MCP 服务器', icon: LuServer },
{ id: 'skills', label: '技能', icon: LuSparkles },
{ id: 'commands', label: '命令', icon: LuTerminal },
{ id: 'hooks', label: '钩子', icon: LuWebhook }
{ id: 'commands', label: '命令', icon: LuTerminal }
]
}
]
const PLACEHOLDER: Record<string, string> = {
memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。',
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。'
}
const TAB_META: Record<Tab, { title: string; desc: string }> = {
general: { title: '常规', desc: '主题色等外观偏好。' },
model: { title: '模型设置', desc: '' },
prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' },
memory: { title: '记忆', desc: PLACEHOLDER.memory },
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' },
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }
}
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
@@ -131,50 +105,60 @@ function ResourcePanel({
}: {
loading: boolean
items: ResourceItem[]
dirs: { user: string; project: string }
/** 当前作用域的资源目录(可多个,如技能的 ~/.pi/agent/skills 与 ~/.agents/skills) */
dirs: { user: string[]; project: string[] }
emptyTitle: string
hints: { user: string; project: string }
/** 与 dirs 一一对应的短标签(展示用;完整路径放 tooltip) */
hints: { user: string[]; project: string[] }
onOpen: (path: string) => void
onOpenDir: (path: string) => void
}): ReactNode {
const [scope, setScope] = useState<'user' | 'project'>('user')
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 (
<div className="flex min-h-0 flex-col">
<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) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
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' ? '全局' : '项目'}
</button>
))}
</div>
{dir && (
<div className="ml-auto flex shrink-0 items-center gap-1">
{scopedDirs.map((d, i) => (
<button
key={d || i}
type="button"
title={dir}
onClick={() => onOpenDir(dir)}
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"
title={d}
onClick={() => onOpenDir(d)}
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" />
打开目录
{scopedHints[i] ?? '打开目录'}
</button>
)}
))}
</div>
</div>
{!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="text-[13px] font-medium text-ink-2">{emptyTitle}</div>
<div className="text-[12px] text-ink-3">{hints[scope]}</div>
<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-zinc-600">{emptyTitle}</div>
{scopedHints.map((h) => (
<div key={h} className="font-mono text-[11.5px] text-zinc-400">
{h}
</div>
))}
</div>
) : (
<div className="space-y-1.5">
@@ -394,7 +378,7 @@ function ModelSettings({
type="password"
value={keyValue}
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"
/>
<button
@@ -910,11 +894,14 @@ export function SettingsModal({
filePath: s.filePath
}))}
dirs={{
user: resources?.dirs.globalSkills ?? '',
project: resources?.dirs.projectSkills ?? ''
user: [resources?.dirs.globalSkills ?? '', resources?.dirs.globalAgentsSkills ?? ''],
project: [resources?.dirs.projectSkills ?? '', resources?.dirs.projectAgentsSkills ?? '']
}}
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)}
onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) =>
@@ -936,11 +923,11 @@ export function SettingsModal({
filePath: c.filePath
}))}
dirs={{
user: resources?.dirs.globalCommands ?? '',
project: resources?.dirs.projectCommands ?? ''
user: [resources?.dirs.globalCommands ?? ''],
project: [resources?.dirs.projectCommands ?? '']
}}
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)}
onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) =>
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import {
LuCheck,
LuChevronDown,
LuMessagesSquare,
LuPencil,
LuPlus,
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({
sessionState,
sessions,
@@ -122,6 +204,8 @@ export function SidebarNav({
const [renameValue, setRenameValue] = useState('')
const [wsMenuOpen, setWsMenuOpen] = useState(false)
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 searchInputRef = useRef<HTMLInputElement>(null)
@@ -139,6 +223,7 @@ export function SidebarNav({
useEffect(() => {
if (collapsed) setWsMenuOpen(false)
else setSessionSwitcher(null)
}, [collapsed])
useEffect(() => {
@@ -340,7 +425,8 @@ export function SidebarNav({
{/* 会话列表区 */}
<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
type="button"
onClick={() => setChatsOpen((v) => !v)}
@@ -401,7 +487,24 @@ export function SidebarNav({
</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]">
{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">
@@ -416,12 +519,23 @@ export function SidebarNav({
data-row
title={sessionTitle(s)}
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 */}
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span>
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */}
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。
激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */}
<span
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 && (
<button
type="button"
@@ -463,6 +577,17 @@ export function SidebarNav({
)}
</div>
{/* 折叠态会话切换浮层(portal 到 body) */}
{sessionSwitcher && (
<SessionSwitcherPortal
anchor={sessionSwitcher}
sessions={sessions}
activeSessionId={activeSessionId}
onOpenSession={onOpenSession}
onClose={() => setSessionSwitcher(null)}
/>
)}
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
{actionMenu && (
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
@@ -1,9 +1,9 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
/** 可展开的思考轨迹(ZCode 式实时预览)。
* 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由
* 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。
/** 可展开的思考轨迹。
* 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
* 再点收起;流式中表头标签保持 shimmer。
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
@@ -22,22 +22,21 @@ export function Thinking({
activeLabel?: string
doneLabel?: string
}): ReactNode {
// 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准
// 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
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 statusWord = streaming ? activeLabel : expanded ? '思考' : doneLabel
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : ''
const statusWord = streaming ? activeLabel : doneLabel
const suffix = expanded ? '' : firstLine
return (
<div className="flex w-full flex-col">
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */}
{/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
<button
type="button"
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"
>
<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"
aria-checked={autoCompactionEnabled}
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
title="上下文接近窗口上限时自动压缩历史(阈值/溢出触发)"
title="接近窗口上限时自动压缩历史"
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
>
<span
@@ -163,7 +163,7 @@ export function UsageCard({
type="button"
onClick={onCompact}
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"
>
立即压缩
@@ -373,6 +373,8 @@ export interface UseAgent {
navigateTree: (entryId: string, options?: NavigateTreeOptions) => void
/** 在会话末尾分叉出新会话(pi fork,原会话保留) */
forkSession: () => void
/** 从锚点分叉:'at' 含该条为止 / 'before' 到其父条目为止 */
forkSessionAt: (entryId: string, position: 'at' | 'before') => void
}
export function useAgent(): UseAgent {
@@ -580,6 +582,18 @@ export function useAgent(): UseAgent {
.finally(() => setForking(false))
}, [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(
() => ({
state,
@@ -607,7 +621,8 @@ export function useAgent(): UseAgent {
setAutoCompaction,
dismissCompactionNotice,
navigateTree,
forkSession
forkSession,
forkSessionAt
}),
[
state,
@@ -635,7 +650,8 @@ export function useAgent(): UseAgent {
setAutoCompaction,
dismissCompactionNotice,
navigateTree,
forkSession
forkSession,
forkSessionAt
]
)
}
+4
View File
@@ -44,6 +44,9 @@ declare global {
minimize: () => Promise<void>
toggleMaximize: () => Promise<void>
close: () => Promise<void>
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
zoom: (factor: number) => void
zoomReset: () => void
onWindowState: (callback: (maximized: boolean) => void) => () => void
send: (text: string, images?: SendImage[]) => Promise<void>
steer: (text: string) => Promise<void>
@@ -81,6 +84,7 @@ declare global {
listForkPoints: () => Promise<ForkPoint[]>
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
forkSession: () => Promise<void>
forkSessionAt: (entryId: string, position: 'at' | 'before') => Promise<void>
getSettings: () => Promise<AppSettings>
getSystemPrompt: () => Promise<string>
setThemeColor: (id: string) => Promise<void>
@@ -312,6 +312,15 @@ a:focus-visible .animated-underline::after {
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
+8 -1
View File
@@ -209,7 +209,14 @@ export interface CommandDetail {
export interface ResourceDetails {
skills: SkillDetail[]
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 的斜杠路径) */