v0.2.0: 会话管理(列表/新建/切换恢复/重命名/回收站删除)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* pi SDK 会话管理冒烟测试(v0.2.0)
|
||||
* 验证:runtime 创建、list、newSession、switchSession(历史恢复)、rename、删除(回收站)。
|
||||
*/
|
||||
import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
console.log('[smoke2] listing sessions (cwd=%s)...', cwd)
|
||||
const before = await SessionManager.list(cwd)
|
||||
console.log('[smoke2] found %d sessions', before.length)
|
||||
|
||||
const initialServices = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async ({ cwd: nextCwd, agentDir: nextAgentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services =
|
||||
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
|
||||
? initialServices
|
||||
: await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir })
|
||||
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.create(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
let rebindCount = 0
|
||||
runtime.setRebindSession(async () => {
|
||||
rebindCount += 1
|
||||
})
|
||||
console.log('[smoke2] runtime ready, session=%s', runtime.session.sessionId)
|
||||
|
||||
// 重命名
|
||||
runtime.session.setSessionName('smoke-测试会话')
|
||||
console.log('[smoke2] renamed to: %s', runtime.session.sessionName)
|
||||
|
||||
// 会话里追加用户+助手消息(直接写会话文件,不触发 LLM)
|
||||
// pi 约定:只有出现 assistant 消息后才会真正落盘(避免空会话残留文件)
|
||||
runtime.session.sessionManager.appendMessage({ role: 'user', content: '这是一条冒烟测试消息', timestamp: Date.now() })
|
||||
runtime.session.sessionManager.appendMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '冒烟回复(不经过 LLM)' }],
|
||||
api: 'openai-completions',
|
||||
provider: 'smoke',
|
||||
model: 'smoke-model',
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 },
|
||||
stopReason: 'stop',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
console.log('[smoke2] messages in session:', runtime.session.messages.length)
|
||||
|
||||
const listAfter = await SessionManager.list(cwd)
|
||||
const mine = listAfter.find((s) => s.name === 'smoke-测试会话' || s.firstMessage.includes('冒烟测试'))
|
||||
console.log('[smoke2] session visible in list: %s (total %d)', Boolean(mine), listAfter.length)
|
||||
if (mine) {
|
||||
console.log('[smoke2] meta: name=%s firstMessage=%s count=%d', mine.name, JSON.stringify(mine.firstMessage.slice(0, 30)), mine.messageCount)
|
||||
|
||||
// 新会话(切换)
|
||||
await runtime.newSession()
|
||||
console.log('[smoke2] newSession ok, rebind=%d, new id=%s, messages=%d', rebindCount, runtime.session.sessionId, runtime.session.messages.length)
|
||||
|
||||
// 切回
|
||||
await runtime.switchSession(mine.path)
|
||||
console.log(
|
||||
'[smoke2] switch back ok, rebind=%d, id=%s, messages restored=%d, name=%s',
|
||||
rebindCount,
|
||||
runtime.session.sessionId,
|
||||
runtime.session.messages.length,
|
||||
runtime.session.sessionName ?? '(none)'
|
||||
)
|
||||
}
|
||||
|
||||
await runtime.dispose()
|
||||
console.log('[smoke2] done')
|
||||
process.exit(0)
|
||||
@@ -38,6 +38,40 @@ export function registerIpc(): AgentService {
|
||||
})
|
||||
)
|
||||
|
||||
// ---- 会话管理 ----
|
||||
ipcMain.handle('sessions:list', () =>
|
||||
service.listSessions().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('sessions:new', () =>
|
||||
service.newSession().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('sessions:open', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
|
||||
return service.openSession(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('sessions:rename', (_event, name: unknown) => {
|
||||
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name')
|
||||
return service.renameSession(name.trim()).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('sessions:delete', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
|
||||
return service.deleteSession(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('app:versions', () => ({
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { app } from 'electron'
|
||||
import { app, shell } from 'electron'
|
||||
import {
|
||||
createAgentSession,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
type CreateAgentSessionResult
|
||||
type AgentSessionRuntime,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type SessionInfo
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
import type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||
const DELTA_FLUSH_MS = 40
|
||||
|
||||
/**
|
||||
* pi AgentSession 的主进程封装:
|
||||
* - 懒初始化会话(默认复用 ~/.pi 的配置、凭据与会话持久化)
|
||||
* - 订阅 pi 事件流,归一化为 AgentUiEvent 转发给渲染层
|
||||
* - 提供 prompt / abort / 状态与统计查询
|
||||
* pi 会话运行时的主进程封装:
|
||||
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
|
||||
* - 复用 ~/.pi 的配置、凭据与会话持久化(sessions/<encoded-cwd>/)
|
||||
* - 归一化 pi 事件为 AgentUiEvent 转发渲染层
|
||||
*/
|
||||
export class AgentService {
|
||||
private result: CreateAgentSessionResult | null = null
|
||||
private initPromise: Promise<CreateAgentSessionResult> | null = null
|
||||
private runtime: AgentSessionRuntime | null = null
|
||||
private initPromise: Promise<AgentSessionRuntime> | null = null
|
||||
private listeners = new Set<(event: AgentUiEvent) => void>()
|
||||
private deltaQueue: AgentUiEvent[] = []
|
||||
private flushTimer: NodeJS.Timeout | null = null
|
||||
private attached: AgentSession | null = null
|
||||
private unsubscribeAttached: (() => void) | null = null
|
||||
|
||||
private get cwd(): string {
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
onEvent(fn: (event: AgentUiEvent) => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
@@ -50,7 +62,7 @@ export class AgentService {
|
||||
}, DELTA_FLUSH_MS)
|
||||
}
|
||||
|
||||
private getSession(): Promise<CreateAgentSessionResult> {
|
||||
private getRuntime(): Promise<AgentSessionRuntime> {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.create().catch((err) => {
|
||||
this.initPromise = null
|
||||
@@ -60,21 +72,83 @@ export class AgentService {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async create(): Promise<CreateAgentSessionResult> {
|
||||
const result = await createAgentSession({
|
||||
cwd: app.getPath('home')
|
||||
private async create(): Promise<AgentSessionRuntime> {
|
||||
const cwd = this.cwd
|
||||
const agentDir = getAgentDir()
|
||||
const initialServices = await createAgentSessionServices({ cwd, agentDir })
|
||||
|
||||
// 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用
|
||||
const factory: CreateAgentSessionRuntimeFactory = async ({
|
||||
cwd: nextCwd,
|
||||
agentDir: nextAgentDir,
|
||||
sessionManager,
|
||||
sessionStartEvent
|
||||
}) => {
|
||||
const services =
|
||||
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
|
||||
? initialServices
|
||||
: await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir })
|
||||
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.continueRecent(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
// 会话被替换(new/switch/fork)后重挂事件订阅并通知渲染层
|
||||
runtime.setRebindSession(async (session) => {
|
||||
this.attach(session)
|
||||
this.emitSwitched(session)
|
||||
})
|
||||
result.session.subscribe((event) => this.handleEvent(event))
|
||||
return result
|
||||
this.attach(runtime.session)
|
||||
return runtime
|
||||
}
|
||||
|
||||
private attach(session: AgentSession): void {
|
||||
if (this.attached === session) return
|
||||
this.unsubscribeAttached?.()
|
||||
this.attached = session
|
||||
this.unsubscribeAttached = session.subscribe((event) => this.handleEvent(event))
|
||||
}
|
||||
|
||||
private emitSwitched(session: AgentSession): void {
|
||||
const state = this.buildState(session)
|
||||
this.emit({
|
||||
type: 'session_switched',
|
||||
state,
|
||||
messages: session.messages.map((m) => toCloneable(m)),
|
||||
activeTools: this.collectToolStates(session)
|
||||
})
|
||||
}
|
||||
|
||||
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
|
||||
private collectToolStates(session: AgentSession): Record<string, unknown> {
|
||||
const tools: Record<string, unknown> = {}
|
||||
for (const message of session.messages) {
|
||||
const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
|
||||
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
|
||||
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
|
||||
if (block.type === 'toolCall' && block.id) {
|
||||
tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' }
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const message of session.messages) {
|
||||
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown }
|
||||
if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue
|
||||
tools[m.toolCallId] = {
|
||||
...((tools[m.toolCallId] as Record<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
|
||||
result: toCloneable(m),
|
||||
status: m.isError ? 'error' : 'done'
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
/** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */
|
||||
async ensureReady(): Promise<SessionState> {
|
||||
try {
|
||||
const { session } = await this.getSession()
|
||||
const state = this.buildState(session)
|
||||
this.emit({ type: 'session_ready', state })
|
||||
return state
|
||||
const runtime = await this.getRuntime()
|
||||
this.emitSwitched(runtime.session)
|
||||
return this.buildState(runtime.session)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
this.emit({ type: 'session_error', message })
|
||||
@@ -174,9 +248,9 @@ export class AgentService {
|
||||
|
||||
private async emitStats(): Promise<void> {
|
||||
try {
|
||||
const { session } = await this.getSession()
|
||||
const stats = session.getSessionStats()
|
||||
const usage = session.getContextUsage()
|
||||
const runtime = await this.getRuntime()
|
||||
const stats = runtime.session.getSessionStats()
|
||||
const usage = runtime.session.getContextUsage()
|
||||
const info: SessionStatsInfo = {
|
||||
tokens: { ...stats.tokens },
|
||||
cost: stats.cost,
|
||||
@@ -189,12 +263,13 @@ export class AgentService {
|
||||
}
|
||||
|
||||
async getState(): Promise<SessionState> {
|
||||
const { session } = await this.getSession()
|
||||
return this.buildState(session)
|
||||
const runtime = await this.getRuntime()
|
||||
return this.buildState(runtime.session)
|
||||
}
|
||||
|
||||
async send(text: string): Promise<void> {
|
||||
const { session } = await this.getSession()
|
||||
const runtime = await this.getRuntime()
|
||||
const session = runtime.session
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(text, { streamingBehavior: 'followUp' })
|
||||
} else {
|
||||
@@ -203,15 +278,74 @@ export class AgentService {
|
||||
}
|
||||
|
||||
async steer(text: string): Promise<void> {
|
||||
const { session } = await this.getSession()
|
||||
await session.steer(text)
|
||||
const runtime = await this.getRuntime()
|
||||
await runtime.session.steer(text)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.result && !this.initPromise) return
|
||||
const { session } = await this.getSession()
|
||||
await session.abort()
|
||||
if (!this.runtime && !this.initPromise) return
|
||||
const runtime = await this.getRuntime()
|
||||
await runtime.session.abort()
|
||||
}
|
||||
|
||||
// ---- 会话管理 ----
|
||||
|
||||
async listSessions(): Promise<SessionMeta[]> {
|
||||
const infos = await SessionManager.list(this.cwd)
|
||||
return infos
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
||||
.map((info) => toSessionMeta(info))
|
||||
}
|
||||
|
||||
async newSession(): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
if (runtime.session.isStreaming) {
|
||||
await runtime.session.abort()
|
||||
}
|
||||
await runtime.newSession()
|
||||
// rebind 回调负责发 session_switched
|
||||
}
|
||||
|
||||
async openSession(path: string): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
if (runtime.session.isStreaming) {
|
||||
await runtime.session.abort()
|
||||
}
|
||||
await runtime.switchSession(path)
|
||||
}
|
||||
|
||||
async renameSession(name: string): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
runtime.session.setSessionName(name)
|
||||
this.emit({ type: 'session_info', name })
|
||||
}
|
||||
|
||||
async deleteSession(path: string): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
const active = runtime.session.sessionFile
|
||||
await shell.trashItem(path).catch((err: unknown) => {
|
||||
throw new Error(`移入回收站失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
if (active && samePath(active, path)) {
|
||||
await runtime.newSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionMeta(info: SessionInfo): SessionMeta {
|
||||
return {
|
||||
id: info.id,
|
||||
path: info.path,
|
||||
name: info.name,
|
||||
firstMessage: info.firstMessage,
|
||||
created: info.created.toISOString(),
|
||||
modified: info.modified.toISOString(),
|
||||
messageCount: info.messageCount
|
||||
}
|
||||
}
|
||||
|
||||
function samePath(a: string, b: string): boolean {
|
||||
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { AgentUiEvent, SessionState } from '@shared/types'
|
||||
import type { AgentUiEvent, SessionMeta, SessionState } from '@shared/types'
|
||||
|
||||
export interface DesktopApi {
|
||||
send: (text: string) => Promise<void>
|
||||
steer: (text: string) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
getState: () => Promise<SessionState>
|
||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
}
|
||||
|
||||
const api: DesktopApi = {
|
||||
send: (text) => ipcRenderer.invoke('agent:send', text),
|
||||
steer: (text) => ipcRenderer.invoke('agent:steer', text),
|
||||
stop: () => ipcRenderer.invoke('agent:stop'),
|
||||
getState: () => ipcRenderer.invoke('agent:state'),
|
||||
versions: () => ipcRenderer.invoke('app:versions'),
|
||||
onEvent: (callback) => {
|
||||
const api = {
|
||||
send: (text: string): Promise<void> => ipcRenderer.invoke('agent:send', text),
|
||||
steer: (text: string): Promise<void> => ipcRenderer.invoke('agent:steer', text),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke('agent:stop'),
|
||||
getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'),
|
||||
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string }> =>
|
||||
ipcRenderer.invoke('app:versions'),
|
||||
listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'),
|
||||
newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'),
|
||||
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
|
||||
renameSession: (name: string): Promise<void> => ipcRenderer.invoke('sessions:rename', name),
|
||||
deleteSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:delete', path),
|
||||
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
||||
ipcRenderer.on('agent:event', listener)
|
||||
return () => {
|
||||
@@ -25,4 +22,6 @@ const api: DesktopApi = {
|
||||
}
|
||||
}
|
||||
|
||||
export type DesktopApi = typeof api
|
||||
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChatView } from './components/ChatView'
|
||||
import { Composer } from './components/Composer'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const { state, send, stop, clearError } = useAgent()
|
||||
const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent()
|
||||
const prefillRef = useRef<string | null>(null)
|
||||
|
||||
const handlePickSuggestion = (text: string): void => {
|
||||
@@ -17,7 +17,16 @@ export default function App(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
||||
<Sidebar sessionState={state.sessionState} stats={state.stats} connected={state.ready} />
|
||||
<Sidebar
|
||||
sessionState={state.sessionState}
|
||||
stats={state.stats}
|
||||
connected={state.ready}
|
||||
sessions={sessions}
|
||||
onNewSession={newSession}
|
||||
onOpenSession={openSession}
|
||||
onRenameSession={renameSession}
|
||||
onDeleteSession={deleteSession}
|
||||
/>
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<ChatView
|
||||
messages={state.messages}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Cpu, MessageSquarePlus, Store } from 'lucide-react'
|
||||
import type { SessionState, SessionStatsInfo } from '@shared/types'
|
||||
import { Check, Cpu, MessageSquare, Pencil, Store, Trash2, X } from 'lucide-react'
|
||||
import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
interface VersionInfo {
|
||||
app: string
|
||||
@@ -21,16 +21,106 @@ function formatTokens(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatModified(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
const yesterday = new Date(now.getTime() - 86400000)
|
||||
if (d.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
function sessionTitle(s: SessionMeta): string {
|
||||
if (s.name?.trim()) return s.name.trim()
|
||||
const first = s.firstMessage?.trim()
|
||||
if (first) return first.length > 36 ? `${first.slice(0, 36)}…` : first
|
||||
return '(空会话)'
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
active,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete
|
||||
}: {
|
||||
session: SessionMeta
|
||||
active: boolean
|
||||
onOpen: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 transition-colors ${
|
||||
active ? 'bg-zinc-800/80' : 'hover:bg-zinc-800/40'
|
||||
}`}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-emerald-400" />}
|
||||
<MessageSquare size={13} className={`shrink-0 ${active ? 'text-emerald-400' : 'text-zinc-600'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12.5px] ${active ? 'text-zinc-100' : 'text-zinc-400'}`}>{sessionTitle(session)}</div>
|
||||
<div className="text-[10.5px] text-zinc-600">
|
||||
{formatModified(session.modified)} · {session.messageCount} 条
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
title="重命名"
|
||||
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRename()
|
||||
}}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="删除(移入回收站)"
|
||||
className="rounded p-1 text-zinc-500 transition-colors hover:bg-red-950/60 hover:text-red-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sessionState,
|
||||
stats,
|
||||
connected
|
||||
connected,
|
||||
sessions,
|
||||
onNewSession,
|
||||
onOpenSession,
|
||||
onRenameSession,
|
||||
onDeleteSession
|
||||
}: {
|
||||
sessionState: SessionState | null
|
||||
stats: SessionStatsInfo | null
|
||||
connected: boolean
|
||||
sessions: SessionMeta[]
|
||||
onNewSession: () => void
|
||||
onOpenSession: (path: string) => void
|
||||
onRenameSession: (name: string) => void
|
||||
onDeleteSession: (path: string) => void
|
||||
}): JSX.Element {
|
||||
const [versions, setVersions] = useState<VersionInfo | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const entranceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,8 +146,20 @@ export function Sidebar({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) renameInputRef.current?.focus()
|
||||
}, [renaming])
|
||||
|
||||
const submitRename = (): void => {
|
||||
const name = renameValue.trim()
|
||||
if (name) onRenameSession(name)
|
||||
setRenaming(false)
|
||||
}
|
||||
|
||||
const activeSessionId = sessionState?.sessionId
|
||||
|
||||
return (
|
||||
<aside ref={entranceRef} className="flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
||||
<aside ref={entranceRef} className="relative flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
||||
{/* 品牌 */}
|
||||
<div className="flex items-center gap-2.5 px-4 pb-4 pt-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||
@@ -69,29 +171,114 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新会话(v0.2 提供) */}
|
||||
{/* 新会话 */}
|
||||
<div className="px-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="会话管理将在 v0.2 提供"
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-400 transition-colors hover:border-zinc-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onNewSession}
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-300 transition-colors hover:border-emerald-900 hover:bg-emerald-950/30 hover:text-emerald-200"
|
||||
>
|
||||
<MessageSquarePlus size={15} />
|
||||
<span className="text-[15px] leading-none text-zinc-500">+</span>
|
||||
新会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 会话列表占位 */}
|
||||
<div className="mt-4 flex-1 overflow-y-auto px-3">
|
||||
{/* 会话列表 */}
|
||||
<div className="mt-4 flex min-h-0 flex-1 flex-col px-3">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</div>
|
||||
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
|
||||
会话列表与历史恢复
|
||||
<br />
|
||||
<span className="text-zinc-700">v0.2 上线</span>
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto pb-2">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
|
||||
暂无历史会话
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
active={s.id === activeSessionId}
|
||||
onOpen={() => onOpenSession(s.path)}
|
||||
onRename={() => {
|
||||
setRenameValue(sessionTitle(s))
|
||||
setRenaming(true)
|
||||
}}
|
||||
onDelete={() => setConfirmDelete(s.path)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
{renaming && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[220px] rounded-xl border border-zinc-800 bg-zinc-900 p-3 shadow-2xl">
|
||||
<div className="mb-2 text-[12.5px] font-medium text-zinc-200">重命名当前会话</div>
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
if (e.key === 'Escape') setRenaming(false)
|
||||
}}
|
||||
className="mb-2.5 w-full rounded-lg border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-[12.5px] text-zinc-100 outline-none focus:border-emerald-700"
|
||||
placeholder="会话名称"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(false)}
|
||||
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[12px] text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<X size={12} />
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitRename}
|
||||
className="flex items-center gap-1 rounded-lg bg-emerald-600 px-2 py-1 text-[12px] text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Check size={12} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
{confirmDelete && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[230px] rounded-xl border border-zinc-800 bg-zinc-900 p-3.5 shadow-2xl">
|
||||
<div className="mb-1 flex items-center gap-2 text-[12.5px] font-medium text-zinc-200">
|
||||
<Trash2 size={13} className="text-red-400" />
|
||||
删除这个会话?
|
||||
</div>
|
||||
<div className="mb-3 text-[11.5px] leading-5 text-zinc-500">会话文件将移入系统回收站,可随时恢复。</div>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="rounded-lg px-2 py-1 text-[12px] text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDeleteSession(confirmDelete)
|
||||
setConfirmDelete(null)
|
||||
}}
|
||||
className="rounded-lg bg-red-600 px-2 py-1 text-[12px] text-white transition-colors hover:bg-red-500"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部状态 */}
|
||||
<div className="border-t border-zinc-800/80 px-4 py-3.5">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import type { AgentUiEvent, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
||||
import type { AgentUiEvent, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
export interface ToolState {
|
||||
toolName: string
|
||||
@@ -64,6 +64,47 @@ function readRole(message: unknown): string {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** 历史消息(会话切换/恢复时)映射为 UI 消息 */
|
||||
function historyToMessages(messages: unknown[]): UiMessage[] {
|
||||
const result: UiMessage[] = []
|
||||
for (const raw of messages) {
|
||||
const role = readRole(raw)
|
||||
if (role === 'toolResult') continue
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
result.push({ key: nextKey('h'), role, raw })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 从历史消息重建工具状态(assistant 的 toolCall 块 + toolResult 消息) */
|
||||
function historyToTools(messages: unknown[]): Record<string, ToolState> {
|
||||
const tools: Record<string, ToolState> = {}
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== 'object') continue
|
||||
const m = message as { role?: string; content?: unknown }
|
||||
if (m.role === 'assistant' && Array.isArray(m.content)) {
|
||||
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
|
||||
if (block?.type === 'toolCall' && block.id) {
|
||||
tools[block.id] = { toolName: block.name ?? 'tool', args: block.arguments, status: 'done' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== 'object') continue
|
||||
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
|
||||
if (m.role === 'toolResult' && typeof m.toolCallId === 'string') {
|
||||
tools[m.toolCallId] = {
|
||||
...(tools[m.toolCallId] ?? { toolName: m.toolName ?? 'tool' }),
|
||||
result: message,
|
||||
status: m.isError ? 'error' : 'done'
|
||||
}
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
if (action.type === 'optimistic-user') {
|
||||
return {
|
||||
@@ -84,6 +125,19 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
return { ...state, ready: true, sessionState: event.state }
|
||||
case 'session_error':
|
||||
return { ...state, sessionError: event.message }
|
||||
case 'session_switched':
|
||||
return {
|
||||
...state,
|
||||
ready: true,
|
||||
error: null,
|
||||
retry: null,
|
||||
streaming: false,
|
||||
queue: { steering: [], followUp: [] },
|
||||
stats: null,
|
||||
sessionState: event.state,
|
||||
messages: historyToMessages(event.messages),
|
||||
tools: historyToTools(event.messages)
|
||||
}
|
||||
case 'agent_start':
|
||||
return { ...state, streaming: true, error: null, retry: null }
|
||||
case 'agent_end':
|
||||
@@ -151,7 +205,10 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
case 'tool_update': {
|
||||
const tools = { ...state.tools }
|
||||
const prev = tools[event.toolCallId]
|
||||
tools[event.toolCallId] = { ...(prev ?? { toolName: event.toolName, status: 'running' as const }), partialResult: event.partialResult }
|
||||
tools[event.toolCallId] = {
|
||||
...(prev ?? { toolName: event.toolName, status: 'running' as const }),
|
||||
partialResult: event.partialResult
|
||||
}
|
||||
return { ...state, tools }
|
||||
}
|
||||
case 'tool_end': {
|
||||
@@ -167,13 +224,20 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
case 'queue_update':
|
||||
return { ...state, queue: { steering: event.steering, followUp: event.followUp } }
|
||||
case 'retry':
|
||||
return { ...state, retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage }, streaming: true }
|
||||
return {
|
||||
...state,
|
||||
retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage },
|
||||
streaming: true
|
||||
}
|
||||
case 'error':
|
||||
return { ...state, error: event.message }
|
||||
case 'session_info':
|
||||
return state.sessionState
|
||||
? { ...state, sessionState: { ...state.sessionState, sessionName: event.name } }
|
||||
: state
|
||||
case 'session_info': {
|
||||
if (!state.sessionState) return state
|
||||
return {
|
||||
...state,
|
||||
sessionState: { ...state.sessionState, sessionName: event.name }
|
||||
}
|
||||
}
|
||||
case 'stats':
|
||||
return { ...state, stats: event.stats }
|
||||
default:
|
||||
@@ -183,45 +247,111 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
|
||||
export interface UseAgent {
|
||||
state: AgentUiState
|
||||
sessions: SessionMeta[]
|
||||
send: (text: string) => void
|
||||
stop: () => void
|
||||
clearError: () => void
|
||||
refreshSessions: () => void
|
||||
newSession: () => void
|
||||
openSession: (path: string) => void
|
||||
renameSession: (name: string) => void
|
||||
deleteSession: (path: string) => void
|
||||
}
|
||||
|
||||
export function useAgent(): UseAgent {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const [sessions, setSessions] = useState<SessionMeta[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.api.onEvent((event) => {
|
||||
dispatch({ type: 'event', event })
|
||||
// 会话集合可能在后台变化(新建/删除/重命名),切换后刷新列表
|
||||
if (event.type === 'session_switched') {
|
||||
void window.api
|
||||
.listSessions()
|
||||
.then(setSessions)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const send = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
||||
window.api.send(trimmed).catch((err: unknown) => {
|
||||
dispatch({
|
||||
type: 'event',
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||
})
|
||||
const refreshSessions = useCallback((): void => {
|
||||
void window.api
|
||||
.listSessions()
|
||||
.then(setSessions)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
const dispatchError = useCallback((err: unknown): void => {
|
||||
dispatch({
|
||||
type: 'event',
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
||||
window.api.send(trimmed).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
window.api.stop().catch((err: unknown) => {
|
||||
dispatch({
|
||||
type: 'event',
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
window.api.stop().catch(dispatchError)
|
||||
}, [dispatchError])
|
||||
|
||||
const clearError = useCallback(() => dispatch({ type: 'clear-error' }), [])
|
||||
|
||||
return useMemo(() => ({ state, send, stop, clearError }), [state, send, stop, clearError])
|
||||
const newSession = useCallback((): void => {
|
||||
window.api.newSession().catch(dispatchError)
|
||||
}, [dispatchError])
|
||||
|
||||
const openSession = useCallback(
|
||||
(path: string): void => {
|
||||
window.api.openSession(path).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
const renameSession = useCallback(
|
||||
(name: string): void => {
|
||||
window.api.renameSession(name).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
const deleteSession = useCallback(
|
||||
(path: string): void => {
|
||||
window.api
|
||||
.deleteSession(path)
|
||||
.then(() => window.api.listSessions())
|
||||
.then(setSessions)
|
||||
.catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
sessions,
|
||||
send,
|
||||
stop,
|
||||
clearError,
|
||||
refreshSessions,
|
||||
newSession,
|
||||
openSession,
|
||||
renameSession,
|
||||
deleteSession
|
||||
}),
|
||||
[state, sessions, send, stop, clearError, refreshSessions, newSession, openSession, renameSession, deleteSession]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
import type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
export type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo }
|
||||
export type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -10,6 +10,11 @@ declare global {
|
||||
stop: () => Promise<void>
|
||||
getState: () => Promise<SessionState>
|
||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||
listSessions: () => Promise<SessionMeta[]>
|
||||
newSession: () => Promise<void>
|
||||
openSession: (path: string) => Promise<void>
|
||||
renameSession: (name: string) => Promise<void>
|
||||
deleteSession: (path: string) => Promise<void>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ export interface SessionStatsInfo {
|
||||
contextPercent?: number
|
||||
}
|
||||
|
||||
/** 会话列表项(SessionManager.list 的映射) */
|
||||
export interface SessionMeta {
|
||||
id: string
|
||||
path: string
|
||||
name?: string
|
||||
firstMessage: string
|
||||
created: string
|
||||
modified: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发到渲染进程的 agent 事件。
|
||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||
@@ -31,6 +42,7 @@ export interface SessionStatsInfo {
|
||||
export type AgentUiEvent =
|
||||
| { type: 'session_ready'; state: SessionState }
|
||||
| { type: 'session_error'; message: string }
|
||||
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
|
||||
| { type: 'agent_start' }
|
||||
| { type: 'agent_end' }
|
||||
| { type: 'agent_settled' }
|
||||
|
||||
@@ -15,6 +15,22 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 — 会话管理
|
||||
|
||||
**交付**:
|
||||
- 主进程 `AgentService` 升级为 `AgentSessionRuntime` 模式:`newSession` / `switchSession`(自动重挂事件订阅 + 通知渲染层)、factory 按 cwd 复用/重建 services
|
||||
- 会话列表 `SessionManager.list`(名称/首消息/条数/修改时间)、重命名(`setSessionName`)、删除(`shell.trashItem` 移入回收站,可恢复)
|
||||
- 渲染层:侧栏真实会话列表(选中高亮 + 绿条指示、悬停操作:重命名/删除)、重命名对话框、删除确认、历史消息与工具状态重建(`session_switched` 事件)
|
||||
- 启动自动 `continueRecent` 恢复最近会话
|
||||
- 验证:typecheck/build 通过;Node 冒烟(list/new/switch 恢复 2 条历史/rename 可见于 list);应用截图核验(列表选中态、历史气泡渲染正确)
|
||||
|
||||
**自省**(v0.2.0 → 指导 v0.3+):
|
||||
1. **pi 的落盘约定**——只有出现 assistant 消息会话文件才真正写入(避免空会话残留)。由此:纯手动模式(不调 LLM)产生的会话不会自动出现在列表里,将来 D2 双模式统一时要在手动路径补 assistant 侧记录(`sendCustomMessage` 或直接 appendMessage)。
|
||||
2. **重命名仅限当前会话**——`setSessionName` 作用于活跃会话;列表项上的重命名按钮对非活跃会话实际是"切过去再改名"或干脆隐藏,当前实现有误导。v0.3 修正:非活跃项隐藏重命名按钮(或先 open 再 rename)。
|
||||
3. **会话列表不实时**——列表在加载和切换后刷新,别的窗口/CLI 新建的会话不会自动出现;缺一个"刷新"入口或文件监听。排 v0.5。
|
||||
4. **cwd 固定为 home**——会话按 cwd 分组,工作区切换(店铺 A/店铺 B 各自会话域)还没设计;等 `packages/agent` 业务域明确后一起做。
|
||||
5. **键盘链路仍未人工实测**(computer-use 无法激活窗口);冒烟+截图继续兜底,但 Enter 发送/Shift+Enter 换行/重命名快捷键需要用户实机过一遍。
|
||||
|
||||
## v0.1.0 — 骨架 + 聊天链路
|
||||
|
||||
**交付**:
|
||||
|
||||
Reference in New Issue
Block a user