✨ feat(vscode): 会话 tab 可交接对话给同事
This commit is contained in:
1 parent
5ee8226167
commit
77037924ef
20 files changed
+938
-26
No files matched your search
@@ -32,6 +32,7 @@ import * as issues from './handlers/issues'
|
||||
import * as managedSessions from './handlers/managedSessions'
|
||||
import * as prFiles from './handlers/prFiles'
|
||||
import * as profileAssets from './handlers/profileAssets'
|
||||
import * as sessionHandoff from './handlers/sessionHandoff'
|
||||
import * as sessions from './handlers/sessions'
|
||||
import * as settings from './handlers/settings'
|
||||
import * as terminals from './handlers/terminals'
|
||||
@@ -440,6 +441,22 @@ export class KanbanWebviewPanel {
|
||||
void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'session-handoff/users') {
|
||||
void sessionHandoff.handleSessionHandoffUsers(this)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'session-handoff/start') {
|
||||
void sessionHandoff.handleSessionHandoffStart(this, msg.sessionId, msg.to)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'session-handoff/accept') {
|
||||
void sessionHandoff.handleSessionHandoffAccept(this, msg.attachmentId)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'session-handoff/refresh') {
|
||||
void sessionHandoff.handleSessionHandoffRefresh(this)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'youtrack/list-projects') {
|
||||
void youtrackIssues.handleListProjects(this, msg.baseUrl, msg.token)
|
||||
return
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
handoffStartedUiPatch,
|
||||
} from './handoff'
|
||||
|
||||
interface RepoCtx {
|
||||
export interface RepoCtx {
|
||||
workspaceRoot: string
|
||||
host: string
|
||||
owner: string
|
||||
@@ -52,17 +52,17 @@ interface RepoCtx {
|
||||
me: string
|
||||
}
|
||||
|
||||
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
|
||||
export function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
|
||||
const id = extra?.id ?? makeNonce()
|
||||
panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 })
|
||||
return id
|
||||
}
|
||||
|
||||
function dismiss(panel: KanbanWebviewPanel, id: string): void {
|
||||
export function dismiss(panel: KanbanWebviewPanel, id: string): void {
|
||||
panel.postMessage({ type: 'toast/dismiss', id })
|
||||
}
|
||||
|
||||
async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
|
||||
export async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot) {
|
||||
toast(panel, 'error', '请先打开一个工作区文件夹')
|
||||
@@ -98,7 +98,7 @@ function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
function scratchDir(prefix: string): Promise<string> {
|
||||
export function scratchDir(prefix: string): Promise<string> {
|
||||
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive'
|
||||
import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import {
|
||||
claudeProjectsRoot,
|
||||
copyClaudeSessionFiles,
|
||||
findClaudeSessionFiles,
|
||||
installClaudeSession,
|
||||
rewritePathInFile,
|
||||
rewritePathInTree,
|
||||
} from '../../cc/sessionBundle'
|
||||
import {
|
||||
buildSessionHandoffManifest,
|
||||
parseSessionHandoffAttachmentName,
|
||||
parseSessionHandoffManifest,
|
||||
sessionHandoffAttachmentName,
|
||||
} from '../../cc/sessionHandoff'
|
||||
import { projectsDirFor } from '../../cc/sessionWatcher'
|
||||
import {
|
||||
deleteIssueAttachment,
|
||||
downloadAttachment,
|
||||
listIssueAttachments,
|
||||
listRepoAssignees,
|
||||
uploadIssueAttachment,
|
||||
} from '../../gitea/api'
|
||||
import { ensureMailboxIssue, findMailboxIssue } from '../../gitea/mailboxIssue'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
||||
import { dismiss, resolveRepoCtx, scratchDir, toast } from './handoffFlow'
|
||||
import { pushManagedSessions } from './managedSessions'
|
||||
|
||||
export interface SessionHandoffPendingItem {
|
||||
attachmentId: number
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Promise<void> {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx) {
|
||||
panel.postMessage({ type: 'session-handoff/users-result', users: [] })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const users = await listRepoAssignees(ctx)
|
||||
panel.postMessage({
|
||||
type: 'session-handoff/users-result',
|
||||
users: users.map(u => u.login).filter(l => l !== ctx.me).sort(),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast(panel, 'error', `读取可指派用户失败:${message}`)
|
||||
panel.postMessage({ type: 'session-handoff/users-result', users: [] })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSessionHandoffRefresh(panel: KanbanWebviewPanel): Promise<void> {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx) {
|
||||
panel.postMessage({ type: 'session-handoff/pending', items: [] })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const items = await listPendingForMe(ctx)
|
||||
panel.postMessage({ type: 'session-handoff/pending', items })
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'panel', message: '拉取待接收会话失败', details: message })
|
||||
panel.postMessage({ type: 'session-handoff/pending', items: [] })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessionId: string, to: string): Promise<void> {
|
||||
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId })
|
||||
let spinner: string | undefined
|
||||
try {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
const data = await readManagedSessions(ctx.workspaceRoot)
|
||||
const session = data.sessions.find(s => s.id === sessionId)
|
||||
if (!session) {
|
||||
toast(panel, 'error', '找不到这个会话记录')
|
||||
return
|
||||
}
|
||||
const files = await findClaudeSessionFiles(sessionId, claudeProjectsRoot())
|
||||
if (!files) {
|
||||
toast(panel, 'error', `找不到会话文件 ${sessionId},无法交接`)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = panel.managedTerminals.get(sessionId)
|
||||
if (existing) {
|
||||
try {
|
||||
existing.dispose()
|
||||
}
|
||||
catch {}
|
||||
await new Promise<void>(r => setTimeout(r, 600))
|
||||
}
|
||||
|
||||
spinner = toast(panel, 'info', `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
|
||||
const manifest = buildSessionHandoffManifest({
|
||||
from: ctx.me,
|
||||
to,
|
||||
workspacePath: ctx.workspaceRoot,
|
||||
session: { id: session.id, name: session.name, profilePath: session.profilePath },
|
||||
})
|
||||
const attachmentName = sessionHandoffAttachmentName({
|
||||
sid: session.id,
|
||||
from: ctx.me,
|
||||
to,
|
||||
name: session.name,
|
||||
})
|
||||
|
||||
const staging = await scratchDir('spx-session-handoff-')
|
||||
const outDir = await scratchDir('spx-session-handoff-out-')
|
||||
let mailboxIndex: number
|
||||
try {
|
||||
const claudeDir = path.join(staging, 'claude')
|
||||
await fsp.mkdir(claudeDir, { recursive: true })
|
||||
await copyClaudeSessionFiles(files, claudeDir)
|
||||
await fsp.writeFile(path.join(staging, HANDOFF_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
mailboxIndex = await ensureMailboxIssue(ctx)
|
||||
const archive = path.join(outDir, attachmentName)
|
||||
await packHandoff(staging, archive)
|
||||
for (const old of await listIssueAttachments({ ...ctx, index: mailboxIndex })) {
|
||||
const parsed = parseSessionHandoffAttachmentName(old.name)
|
||||
if (parsed && parsed.sid === session.id && parsed.to === to)
|
||||
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId: old.id })
|
||||
}
|
||||
await uploadIssueAttachment({
|
||||
...ctx,
|
||||
index: mailboxIndex,
|
||||
name: attachmentName,
|
||||
data: await fsp.readFile(archive),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `会话交接打包/上传失败 ${sessionId}`, details: message })
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'error', `交接失败:${message}`)
|
||||
return
|
||||
}
|
||||
finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
|
||||
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
session.handedOffTo = to
|
||||
session.handedOffAt = Date.now()
|
||||
await writeManagedSessions(ctx.workspaceRoot, data)
|
||||
await pushAll(panel)
|
||||
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'success', `已交给 ${to}(对方在会话 tab 待接收)`)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `会话交接未预期错误 ${sessionId}`, details: message })
|
||||
toast(panel, 'error', `交接失败:${message}`)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, attachmentId: number): Promise<void> {
|
||||
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId: '' })
|
||||
let spinner: string | undefined
|
||||
try {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
const mailboxIndex = await findMailboxIssue(ctx)
|
||||
if (mailboxIndex === undefined) {
|
||||
toast(panel, 'error', '还没有会话交接邮箱')
|
||||
return
|
||||
}
|
||||
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
|
||||
const asset = assets.find(a => a.id === attachmentId)
|
||||
if (!asset) {
|
||||
toast(panel, 'error', '找不到这份待接收附件')
|
||||
return
|
||||
}
|
||||
const named = parseSessionHandoffAttachmentName(asset.name)
|
||||
if (!named || named.to !== ctx.me) {
|
||||
toast(panel, 'error', '这份交接不是给你的')
|
||||
return
|
||||
}
|
||||
|
||||
spinner = toast(panel, 'info', `正在接收「${named.name || named.sid.slice(0, 8)}」…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
|
||||
const archiveDir = await scratchDir('spx-session-handoff-in-')
|
||||
const extracted = await scratchDir('spx-session-handoff-ex-')
|
||||
try {
|
||||
const buf = await downloadAttachment({ token: ctx.token, url: asset.browser_download_url })
|
||||
const archive = path.join(archiveDir, asset.name)
|
||||
await fsp.writeFile(archive, buf)
|
||||
await unpackHandoff(archive, extracted)
|
||||
const manifest = parseSessionHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'))
|
||||
if (manifest.to !== ctx.me)
|
||||
throw new Error(`清单接收人是 ${manifest.to},不是你`)
|
||||
const claudeDir = path.join(extracted, 'claude')
|
||||
if (manifest.workspacePath !== ctx.workspaceRoot) {
|
||||
for (const { id } of manifest.claude) {
|
||||
await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), manifest.workspacePath, ctx.workspaceRoot)
|
||||
await rewritePathInTree(path.join(claudeDir, id), manifest.workspacePath, ctx.workspaceRoot)
|
||||
}
|
||||
}
|
||||
const dstProjectsDir = projectsDirFor(ctx.workspaceRoot)
|
||||
await fsp.mkdir(dstProjectsDir, { recursive: true })
|
||||
for (const { id } of manifest.claude) {
|
||||
await installClaudeSession({
|
||||
sid: id,
|
||||
srcDir: claudeDir,
|
||||
dstProjectsDir,
|
||||
projectsRoot: claudeProjectsRoot(),
|
||||
})
|
||||
}
|
||||
|
||||
const data = await readManagedSessions(ctx.workspaceRoot)
|
||||
const existing = data.sessions.find(s => s.id === manifest.session.id)
|
||||
const profilePath = resolveProfilePath(manifest.session.profilePath)
|
||||
if (existing) {
|
||||
if (manifest.session.name)
|
||||
existing.name = manifest.session.name
|
||||
existing.profilePath = profilePath
|
||||
}
|
||||
else {
|
||||
data.sessions.push({
|
||||
id: manifest.session.id,
|
||||
name: manifest.session.name || named.name || manifest.session.id.slice(0, 8),
|
||||
profilePath,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
await writeManagedSessions(ctx.workspaceRoot, data)
|
||||
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'panel', message: '接收后删附件失败', details: message })
|
||||
})
|
||||
}
|
||||
finally {
|
||||
await fsp.rm(archiveDir, { recursive: true, force: true }).catch(() => {})
|
||||
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
await pushAll(panel)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'success', `已接收「${named.name || named.sid.slice(0, 8)}」,来自 ${named.from}`)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `接收会话失败 attachment=${attachmentId}`, details: message })
|
||||
toast(panel, 'error', `接收失败:${message}`)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
async function listPendingForMe(ctx: { host: string, token: string, owner: string, repo: string, me: string }): Promise<SessionHandoffPendingItem[]> {
|
||||
const mailboxIndex = await findMailboxIssue(ctx)
|
||||
if (mailboxIndex === undefined)
|
||||
return []
|
||||
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
|
||||
const items: SessionHandoffPendingItem[] = []
|
||||
for (const a of assets) {
|
||||
const parsed = parseSessionHandoffAttachmentName(a.name)
|
||||
if (!parsed || parsed.to !== ctx.me)
|
||||
continue
|
||||
items.push({
|
||||
attachmentId: a.id,
|
||||
sid: parsed.sid,
|
||||
from: parsed.from,
|
||||
to: parsed.to,
|
||||
name: parsed.name,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
async function pushAll(panel: KanbanWebviewPanel): Promise<void> {
|
||||
await pushManagedSessions(panel)
|
||||
await handleSessionHandoffRefresh(panel)
|
||||
}
|
||||
@@ -15,6 +15,14 @@ export interface ManagedSessionShowItem extends ManagedSession {
|
||||
tabOpen?: boolean
|
||||
}
|
||||
|
||||
export interface SessionHandoffPendingItem {
|
||||
attachmentId: number
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ManagedSessionsShowData {
|
||||
sessions: ManagedSessionShowItem[]
|
||||
}
|
||||
@@ -46,6 +54,9 @@ export type ExtensionToWebview
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
|
||||
| { type: 'handoff/done', issueNumber: number }
|
||||
| { type: 'session-handoff/users-result', users: string[] }
|
||||
| { type: 'session-handoff/pending', items: SessionHandoffPendingItem[] }
|
||||
| { type: 'session-handoff/done', sessionId: string }
|
||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||
| { type: 'issue/select-by-number', issueNumber: number }
|
||||
| {
|
||||
@@ -173,6 +184,10 @@ export type WebviewToExtension
|
||||
| { type: 'handoff/users', issueNumber: number }
|
||||
| { type: 'handoff/start', issueNumber: number, to: string }
|
||||
| { type: 'handoff/accept', issueNumber: number }
|
||||
| { type: 'session-handoff/users' }
|
||||
| { type: 'session-handoff/start', sessionId: string, to: string }
|
||||
| { type: 'session-handoff/accept', attachmentId: number }
|
||||
| { type: 'session-handoff/refresh' }
|
||||
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
||||
|
||||
Reference in new issue
Block a user