From 77037924ef79f825337c7bb84151f8068d39dec4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 27 Aug 2026 15:14:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=20tab=20=E5=8F=AF=E4=BA=A4=E6=8E=A5=E5=AF=B9=E8=AF=9D=E7=BB=99?= =?UTF-8?q?=E5=90=8C=E4=BA=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui/internal/issue/loader.go | 30 ++ vscode/package.json | 2 +- vscode/src/cc/sessionHandoff.test.ts | 104 ++++++ vscode/src/cc/sessionHandoff.ts | 153 +++++++++ vscode/src/gitea/api.ts | 75 +++++ vscode/src/gitea/issueLoader.ts | 4 +- vscode/src/gitea/mailboxIssue.ts | 38 +++ vscode/src/panel/KanbanPanel.ts | 17 + vscode/src/panel/handlers/handoffFlow.ts | 10 +- vscode/src/panel/handlers/sessionHandoff.ts | 302 ++++++++++++++++++ vscode/src/panel/messages.ts | 15 + vscode/src/sessions/managedStore.ts | 6 +- vscode/src/webhook/coordinator.ts | 10 + vscode/webview-ui/src/App.tsx | 36 ++- .../webview-ui/src/components/BottomTabs.tsx | 17 +- .../src/components/HandoffModal.tsx | 20 +- .../src/components/ManagedSessionsPanel.tsx | 71 +++- .../src/hooks/useManagedSessions.ts | 37 +++ vscode/webview-ui/src/lib/messages.ts | 15 + vscode/webview-ui/src/types.ts | 2 + 20 files changed, 938 insertions(+), 26 deletions(-) create mode 100644 vscode/src/cc/sessionHandoff.test.ts create mode 100644 vscode/src/cc/sessionHandoff.ts create mode 100644 vscode/src/gitea/mailboxIssue.ts create mode 100644 vscode/src/panel/handlers/sessionHandoff.ts diff --git a/tui/internal/issue/loader.go b/tui/internal/issue/loader.go index b941c4d..f5f49cf 100644 --- a/tui/internal/issue/loader.go +++ b/tui/internal/issue/loader.go @@ -225,6 +225,35 @@ func fetchPRStatus(ctx context.Context, client GiteaClient, owner, repo, prStr s // LoadIssues loads all issues assigned to or created by the current user, // resolves state from comment blobs, and enriches with live PR and dependency // data (skipped for done-column issues). +const mailboxTitle = "spx-session-mailbox" +const mailboxLabel = "spx-session-mailbox" +const mailboxMarker = "" + +func isMailboxIssue(iss gitea.Issue) bool { + if iss.Title == mailboxTitle { + return true + } + if strings.Contains(iss.Body, mailboxMarker) { + return true + } + for _, l := range iss.Labels { + if l.Name == mailboxLabel { + return true + } + } + return false +} + +func filterMailboxIssues(issues []gitea.Issue) []gitea.Issue { + out := issues[:0] + for _, iss := range issues { + if !isMailboxIssue(iss) { + out = append(out, iss) + } + } + return out +} + func LoadIssues(ctx context.Context, client GiteaClient, owner, repo, workspaceRoot string) ([]Issue, error) { // Kick off user + repo-wide comments in parallel. userCh := make(chan *gitea.User, 1) @@ -291,6 +320,7 @@ func LoadIssues(ctx context.Context, client GiteaClient, owner, repo, workspaceR } issues := mergeIssues(assigned, created) + issues = filterMailboxIssues(issues) buckets := groupComments(allComments) // Pre-compute columns to decide which issues need live PR/deps queries. diff --git a/vscode/package.json b/vscode/package.json index fdebb20..6ae1a33 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.99", + "version": "0.2.100", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/cc/sessionHandoff.test.ts b/vscode/src/cc/sessionHandoff.test.ts new file mode 100644 index 0000000..9a3315f --- /dev/null +++ b/vscode/src/cc/sessionHandoff.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { + buildSessionHandoffManifest, + isMailboxIssue, + MAILBOX_MARKER, + MAILBOX_TITLE, + parseSessionHandoffAttachmentName, + parseSessionHandoffManifest, + sessionHandoffAttachmentName, + slugSessionName, +} from './sessionHandoff' + +const SID = '11111111-1111-4111-8111-111111111111' + +describe('isMailboxIssue', () => { + it('title 命中', () => { + expect(isMailboxIssue({ title: MAILBOX_TITLE })).toBe(true) + }) + it('label 命中', () => { + expect(isMailboxIssue({ title: '其他', labels: [{ name: 'spx-session-mailbox' }] })).toBe(true) + }) + it('body marker 命中', () => { + expect(isMailboxIssue({ title: 'x', body: `hello ${MAILBOX_MARKER}` })).toBe(true) + }) + it('普通工单不是', () => { + expect(isMailboxIssue({ title: '修登录', body: 'foo', labels: [{ name: 'bug' }] })).toBe(false) + }) +}) + +describe('sessionHandoffAttachmentName', () => { + it('编解码往返', () => { + const name = sessionHandoffAttachmentName({ + sid: SID, + from: 'chw', + to: 'cruldra', + name: '测试服初始化', + }) + expect(name).toBe(`spx-handoff-session!${SID}!chw!cruldra!测试服初始化.tgz`) + expect(parseSessionHandoffAttachmentName(name)).toEqual({ + sid: SID, + from: 'chw', + to: 'cruldra', + name: '测试服初始化', + }) + }) + it('空名字也合法', () => { + const name = sessionHandoffAttachmentName({ sid: SID, from: 'a', to: 'b', name: '' }) + expect(parseSessionHandoffAttachmentName(name)).toEqual({ + sid: SID, + from: 'a', + to: 'b', + name: '', + }) + }) + it('slug 去掉非法字符', () => { + expect(slugSessionName('foo/bar baz')).toBe('foo_bar_baz') + }) + it('sid 非 UUID → 抛错', () => { + expect(() => sessionHandoffAttachmentName({ sid: '../x', from: 'a', to: 'b', name: '' })).toThrow(/UUID/) + }) + it('login 含 ! → 抛错', () => { + expect(() => sessionHandoffAttachmentName({ sid: SID, from: 'a!b', to: 'c', name: '' })).toThrow(/from/) + }) + it('工单移交包名解析不到', () => { + expect(parseSessionHandoffAttachmentName('spx-handoff-issue-12.tgz')).toBeUndefined() + }) +}) + +describe('parseSessionHandoffManifest', () => { + const good = { + version: 1, + kind: 'managed-session', + from: 'chw', + to: 'cruldra', + createdAt: '2026-08-27T00:00:00Z', + workspacePath: '/home/chw/proj', + session: { id: SID, name: '测试服初始化', profilePath: '/x/offical.json' }, + claude: [{ id: SID }], + } + + it('合法清单原样返回', () => { + expect(parseSessionHandoffManifest(JSON.stringify(good))).toEqual(good) + }) + it('kind 不对 → 抛错', () => { + expect(() => parseSessionHandoffManifest(JSON.stringify({ ...good, kind: 'issue' }))).toThrow(/kind/) + }) + it('缺少 workspacePath → 抛错', () => { + const { workspacePath: _, ...rest } = good + expect(() => parseSessionHandoffManifest(JSON.stringify(rest))).toThrow(/workspacePath/) + }) + it('claude 空 → 抛错', () => { + expect(() => parseSessionHandoffManifest(JSON.stringify({ ...good, claude: [] }))).toThrow(/claude/) + }) + it('build 再 parse 往返', () => { + const built = buildSessionHandoffManifest({ + from: 'chw', + to: 'cruldra', + workspacePath: '/home/chw/proj', + session: { id: SID, name: 'n' }, + createdAt: '2026-08-27T00:00:00Z', + }) + expect(parseSessionHandoffManifest(JSON.stringify(built))).toEqual(built) + }) +}) diff --git a/vscode/src/cc/sessionHandoff.ts b/vscode/src/cc/sessionHandoff.ts new file mode 100644 index 0000000..a552038 --- /dev/null +++ b/vscode/src/cc/sessionHandoff.ts @@ -0,0 +1,153 @@ +/** + * 会话交接:mailbox 工单识别、附件文件名、managed-session 清单。 + * + * 与工单移交的 handoff.json 分开:那边有 issue/worktree/四种 sid,这边只有一个 + * claude 会话。混用解析会把会话包当工单包读崩。 + */ + +export const MAILBOX_TITLE = 'spx-session-mailbox' +export const MAILBOX_LABEL = 'spx-session-mailbox' +export const MAILBOX_MARKER = '' +export const MAILBOX_BODY = `${MAILBOX_MARKER} +Superwork 会话交接邮箱。不要当任务处理,不要关闭。 +` + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const LOGIN_RE = /^[\w.-]+$/ +const FILENAME_RE = /^spx-handoff-session!([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([\w.-]+)!([^!]*)\.tgz$/i + +export interface SessionHandoffManifest { + version: 1 + kind: 'managed-session' + from: string + to: string + createdAt: string + workspacePath: string + session: { id: string, name: string, profilePath?: string } + claude: Array<{ id: string }> +} + +export interface SessionHandoffAttachmentRef { + sid: string + from: string + to: string + name: string +} + +export function isMailboxIssue(issue: { + title?: string + body?: string + labels?: Array<{ name: string }> | null +}): boolean { + if (issue.title === MAILBOX_TITLE) + return true + if ((issue.labels ?? []).some(l => l.name === MAILBOX_LABEL)) + return true + if (typeof issue.body === 'string' && issue.body.includes(MAILBOX_MARKER)) + return true + return false +} + +export function slugSessionName(name: string): string { + const trimmed = name.trim() + if (!trimmed) + return '' + return trimmed.replace(/[^\p{L}\p{N}._-]+/gu, '_').slice(0, 40) +} + +export function sessionHandoffAttachmentName(opts: { + sid: string + from: string + to: string + name: string +}): string { + if (!UUID_RE.test(opts.sid)) + throw new Error(`会话 id 不是 UUID:${opts.sid}`) + if (!LOGIN_RE.test(opts.from)) + throw new Error(`from 含非法字符:${opts.from}`) + if (!LOGIN_RE.test(opts.to)) + throw new Error(`to 含非法字符:${opts.to}`) + return `spx-handoff-session!${opts.sid}!${opts.from}!${opts.to}!${slugSessionName(opts.name)}.tgz` +} + +export function parseSessionHandoffAttachmentName(filename: string): SessionHandoffAttachmentRef | undefined { + const m = FILENAME_RE.exec(filename) + if (!m) + return undefined + return { sid: m[1], from: m[2], to: m[3], name: m[4] ?? '' } +} + +function optString(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +export function parseSessionHandoffManifest(json: string): SessionHandoffManifest { + const raw = JSON.parse(json) as Record + if (!raw || typeof raw !== 'object') + throw new Error('handoff.json 不是对象') + if (raw.version !== 1) + throw new Error(`handoff.json version 不支持:${String(raw.version)}`) + if (raw.kind !== 'managed-session') + throw new Error(`handoff.json kind 不是 managed-session:${String(raw.kind)}`) + const from = optString(raw.from) + const to = optString(raw.to) + const createdAt = optString(raw.createdAt) + const workspacePath = optString(raw.workspacePath) + if (!from || !to || !createdAt || !workspacePath) + throw new Error('handoff.json 缺少 from / to / createdAt / workspacePath') + if (!LOGIN_RE.test(from) || !LOGIN_RE.test(to)) + throw new Error('handoff.json from/to 非法') + const sessionRaw = (raw.session ?? {}) as Record + const id = optString(sessionRaw.id) + const name = typeof sessionRaw.name === 'string' ? sessionRaw.name : '' + if (!id || !UUID_RE.test(id)) + throw new Error(`handoff.json session.id 非法:${String(sessionRaw.id)}`) + const claudeRaw = Array.isArray(raw.claude) ? raw.claude : [] + const claude = claudeRaw.map((e) => { + const o = e as Record + const cid = optString(o.id) + if (!cid || !UUID_RE.test(cid)) + throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`) + return { id: cid } + }) + if (claude.length === 0) + throw new Error('handoff.json 没有 claude 会话文件') + const profilePath = optString(sessionRaw.profilePath) + return { + version: 1, + kind: 'managed-session', + from, + to, + createdAt, + workspacePath, + session: { id, name, ...(profilePath ? { profilePath } : {}) }, + claude, + } +} + +export function buildSessionHandoffManifest(opts: { + from: string + to: string + workspacePath: string + session: { id: string, name: string, profilePath?: string } + createdAt?: string +}): SessionHandoffManifest { + if (!UUID_RE.test(opts.session.id)) + throw new Error(`会话 id 不是 UUID:${opts.session.id}`) + if (!LOGIN_RE.test(opts.from) || !LOGIN_RE.test(opts.to)) + throw new Error('from/to 非法') + return { + version: 1, + kind: 'managed-session', + from: opts.from, + to: opts.to, + createdAt: opts.createdAt ?? new Date().toISOString(), + workspacePath: opts.workspacePath, + session: { + id: opts.session.id, + name: opts.session.name, + ...(opts.session.profilePath ? { profilePath: opts.session.profilePath } : {}), + }, + claude: [{ id: opts.session.id }], + } +} diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index bdde6b4..abeca53 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -92,6 +92,8 @@ export async function listIssuesByFilter(opts: { /** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */ filter?: 'assigned_by' | 'created_by' user?: string + /** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */ + labels?: string }): Promise { const out: GiteaIssue[] = [] let page = 1 @@ -103,6 +105,8 @@ export async function listIssuesByFilter(opts: { url.searchParams.set('state', 'all') if (opts.filter && opts.user) url.searchParams.set(opts.filter, opts.user) + if (opts.labels) + url.searchParams.set('labels', opts.labels) url.searchParams.set('limit', String(PAGE_SIZE)) url.searchParams.set('page', String(page)) @@ -119,6 +123,77 @@ export async function listIssuesByFilter(opts: { return out } +export interface GiteaLabel { + id: number + name: string + color: string +} + +export async function listRepoLabels(opts: { + host: string + token: string + owner: string + repo: string +}): Promise { + const out: GiteaLabel[] = [] + let page = 1 + while (true) { + const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`) + url.searchParams.set('limit', String(PAGE_SIZE)) + url.searchParams.set('page', String(page)) + const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) + await ensureOk(res) + const batch = await res.json() as GiteaLabel[] + if (!Array.isArray(batch) || batch.length === 0) + break + out.push(...batch) + if (batch.length < PAGE_SIZE) + break + page += 1 + } + return out +} + +export async function createRepoLabel(opts: { + host: string + token: string + owner: string + repo: string + name: string + color: string + description?: string +}): Promise { + const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`, { + method: 'POST', + headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: opts.name, color: opts.color, description: opts.description ?? '' }), + }) + await ensureOk(res) + return (await res.json()) as GiteaLabel +} + +export async function createIssue(opts: { + host: string + token: string + owner: string + repo: string + title: string + body: string + labels?: number[] +}): Promise { + const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`, { + method: 'POST', + headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: opts.title, + body: opts.body, + ...(opts.labels && opts.labels.length > 0 ? { labels: opts.labels } : {}), + }), + }) + await ensureOk(res) + return (await res.json()) as GiteaIssue +} + /** * Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which * returns every comment in the repo across all issues. Paginated until a short diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index adc7b69..9eddd3a 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -20,6 +20,7 @@ import type { GiteaComment, GiteaIssue } from './api' import type { Issue, IssueColumn } from './types' import * as fs from 'node:fs' +import { isMailboxIssue } from '../cc/sessionHandoff' import { resolveWorktreePath } from '../git/worktree' import { getCurrentUser, @@ -448,6 +449,7 @@ export async function loadIssues(opts: { merged = mergeIssues(assigned, created) allComments = comments } + merged = merged.filter(issue => !isMailboxIssue(issue)) const buckets = groupCommentsByIssue(allComments) // 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。 @@ -527,7 +529,7 @@ export async function loadSingleIssue(opts: { const { host, token, owner, repo, workspaceRoot, issueNumber } = opts const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) - if (!issue) + if (!issue || isMailboxIssue(issue)) return null const [comments, prerequisite] = await Promise.all([ diff --git a/vscode/src/gitea/mailboxIssue.ts b/vscode/src/gitea/mailboxIssue.ts new file mode 100644 index 0000000..1c0e3c6 --- /dev/null +++ b/vscode/src/gitea/mailboxIssue.ts @@ -0,0 +1,38 @@ +import type { GiteaLabel } from './api' +import { isMailboxIssue, MAILBOX_BODY, MAILBOX_LABEL, MAILBOX_TITLE } from '../cc/sessionHandoff' +import { createIssue, createRepoLabel, listIssuesByFilter, listRepoLabels } from './api' + +export async function findMailboxIssue(opts: { + host: string + token: string + owner: string + repo: string +}): Promise { + const labels = await listRepoLabels(opts) + if (!labels.some(l => l.name === MAILBOX_LABEL)) + return undefined + const issues = await listIssuesByFilter({ ...opts, labels: MAILBOX_LABEL }) + return issues.find(i => isMailboxIssue(i))?.number +} + +export async function ensureMailboxIssue(opts: { + host: string + token: string + owner: string + repo: string +}): Promise { + const existing = await findMailboxIssue(opts) + if (existing !== undefined) + return existing + const labels = await listRepoLabels(opts) + let label: GiteaLabel | undefined = labels.find(l => l.name === MAILBOX_LABEL) + if (!label) + label = await createRepoLabel({ ...opts, name: MAILBOX_LABEL, color: '586069', description: 'Superwork session mailbox' }) + const created = await createIssue({ + ...opts, + title: MAILBOX_TITLE, + body: MAILBOX_BODY, + labels: [label.id], + }) + return created.number +} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 3c960cd..8d1d124 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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 diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 3b3bb5b..447b11e 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -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 { +export async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise { 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 { +export function scratchDir(prefix: string): Promise { return fsp.mkdtemp(path.join(os.tmpdir(), prefix)) } diff --git a/vscode/src/panel/handlers/sessionHandoff.ts b/vscode/src/panel/handlers/sessionHandoff.ts new file mode 100644 index 0000000..a60524e --- /dev/null +++ b/vscode/src/panel/handlers/sessionHandoff.ts @@ -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 { + 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 { + 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 { + 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(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 { + 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 { + 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 { + await pushManagedSessions(panel) + await handleSessionHandoffRefresh(panel) +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index a860a5b..a765971 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 } diff --git a/vscode/src/sessions/managedStore.ts b/vscode/src/sessions/managedStore.ts index 4d94f40..3d25291 100644 --- a/vscode/src/sessions/managedStore.ts +++ b/vscode/src/sessions/managedStore.ts @@ -15,6 +15,8 @@ export interface ManagedSession { name: string profilePath?: string createdAt: number + handedOffTo?: string + handedOffAt?: number } export interface ManagedSessionsData { @@ -46,7 +48,9 @@ export async function readManagedSessions(workspaceRoot: string): Promise 0 ? s.handedOffTo : undefined + const handedOffAt = typeof s?.handedOffAt === 'number' ? s.handedOffAt : undefined + return { id, name, profilePath, createdAt, handedOffTo, handedOffAt } }) .filter(s => s.id !== '') : [] diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index e4aee34..ad657a1 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -18,6 +18,7 @@ import { env, Uri, window, workspace } from 'vscode' import { loginForToken } from '../auth/identity' import { getToken } from '../auth/secrets' import { getReviewPrompt } from '../cc/prompts' +import { isMailboxIssue } from '../cc/sessionHandoff' import { detectRepo } from '../git/remote' import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' @@ -525,6 +526,15 @@ class WebhookCoordinator { return } + if (isMailboxIssue({ title: event.title, body: event.body })) { + logger.add({ + level: 'info', + source: 'webhook', + message: `issue #${event.issueNumber} 是会话交接 mailbox,跳过看板`, + }) + return + } + const nonceMatch = event.body.match(//i) const nonce = nonceMatch ? nonceMatch[1] : null // Promote the in-flight brainstorm terminal into the panel's diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 61fe910..fe3126c 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -96,10 +96,16 @@ export function App() { importNamedSession, importableSessions, importableLoading, + pendingHandoffs, + sessionHandoffUsers, + startSessionHandoff, + submitSessionHandoff, + acceptSessionHandoff, } = useManagedSessions() const [showNewIssueModal, setShowNewIssueModal] = useState(false) const [showLogs, setShowLogs] = useState(false) const [handoffIssue, setHandoffIssue] = useState(null) + const [handoffSession, setHandoffSession] = useState<{ id: string, name: string } | null>(null) const [selectedId, setSelectedId] = useState(null) // 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦 // effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。 @@ -182,6 +188,8 @@ export function App() { return if (settingsOpen) return + if (handoffIssue !== null || handoffSession !== null) + return function onKeyDown(e: KeyboardEvent): void { // Skip when typing in form fields. @@ -403,6 +411,13 @@ export function App() { importableLoading={importableLoading} onListImportableSessions={listImportableNamedSessions} onImportNamedSession={importNamedSession} + pendingHandoffs={pendingHandoffs} + onSessionHandoff={(id) => { + const s = managedSessions.sessions.find(x => x.id === id) + setHandoffSession({ id, name: s?.name ?? id.slice(0, 8) }) + startSessionHandoff(id) + }} + onAcceptSessionHandoff={acceptSessionHandoff} /> @@ -465,14 +480,29 @@ export function App() { /> setHandoffIssue(null)} - onSubmit={(n, to) => { - startHandoff(n, to) + onSubmit={(to) => { + if (handoffIssue !== null) + startHandoff(handoffIssue, to) setHandoffIssue(null) }} /> + setHandoffSession(null)} + onSubmit={(to) => { + if (handoffSession) + submitSessionHandoff(handoffSession.id, to) + setHandoffSession(null) + }} + /> void onImportNamedSession: (sessionId: string, name: string) => void + pendingHandoffs: SessionHandoffPendingItem[] + onSessionHandoff: (sessionId: string) => void + onAcceptSessionHandoff: (attachmentId: number) => void } export function BottomTabs(props: BottomTabsProps) { @@ -98,6 +101,7 @@ export function BottomTabs(props: BottomTabsProps) { onClick={() => setTab('sessions')} icon={} title="会话" + badge={props.pendingHandoffs.length} />
void icon: React.ReactNode title: string + badge?: number } -function TabButton({ active, onClick, icon, title }: TabButtonProps) { +function TabButton({ active, onClick, icon, title, badge }: TabButtonProps) { return ( ) } diff --git a/vscode/webview-ui/src/components/HandoffModal.tsx b/vscode/webview-ui/src/components/HandoffModal.tsx index 138d7d1..4ec5e65 100644 --- a/vscode/webview-ui/src/components/HandoffModal.tsx +++ b/vscode/webview-ui/src/components/HandoffModal.tsx @@ -4,14 +4,15 @@ import { SelectMenu } from './ui/select-menu' interface Props { open: boolean - issueNumber: number | null - /** null = 候选列表还在加载 */ + title: string + description: string + confirmLabel?: string users: string[] | null onCancel: () => void - onSubmit: (issueNumber: number, to: string) => void + onSubmit: (to: string) => void } -export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: Props) { +export function HandoffModal({ open, title, description, confirmLabel = '移交', users, onCancel, onSubmit }: Props) { const [to, setTo] = useState('') useEffect(() => { @@ -32,7 +33,7 @@ export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: P setTo(users[0]) }, [users, to]) - if (!open || issueNumber === null) + if (!open) return null return ( @@ -44,15 +45,14 @@ export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: P >

- 移交 # - {issueNumber} + {title}

- 会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。 + {description}

{users === null ? ( @@ -77,10 +77,10 @@ export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: P
diff --git a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx index 79ead17..3d0b405 100644 --- a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx +++ b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx @@ -8,8 +8,9 @@ */ import type { ClaudeProfile } from '../hooks/useIssues' +import type { SessionHandoffPendingItem } from '../lib/messages' import type { ManagedSession, ManagedSessionsData } from '../types' -import { Download, Plus, Terminal, Trash2, X } from 'lucide-react' +import { Download, Plus, Terminal, Trash2, UserRoundPlus, X } from 'lucide-react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { SelectMenu } from './ui/select-menu' @@ -26,6 +27,9 @@ interface ManagedSessionsPanelProps { importableLoading: boolean onListImportable: () => void onImport: (sessionId: string, name: string) => void + pendingHandoffs: SessionHandoffPendingItem[] + onHandoff: (sessionId: string) => void + onAcceptHandoff: (attachmentId: number) => void } function basename(p?: string): string { @@ -73,6 +77,9 @@ export function ManagedSessionsPanel({ importableLoading, onListImportable, onImport, + pendingHandoffs, + onHandoff, + onAcceptHandoff, }: ManagedSessionsPanelProps) { const [selectedProfile, setSelectedProfile] = useState('') const [name, setName] = useState('') @@ -242,6 +249,33 @@ export function ManagedSessionsPanel({ + {pendingHandoffs.length > 0 && ( +
+
待接收
+
    + {pendingHandoffs.map(p => ( +
  • +
    +
    {p.name || p.sid.slice(0, 8)}
    +
    + 来自 + {' '} + {p.from} +
    +
    + +
  • + ))} +
+
+ )} + {/* 下方会话列表 */}
{sessions.length === 0 @@ -261,6 +295,7 @@ export function ManagedSessionsPanel({ onResume={onResume} onDelete={onDelete} onCloseTab={onCloseTab} + onHandoff={onHandoff} /> ))} @@ -277,9 +312,10 @@ interface SessionRowProps { onResume: (id: string, profilePath?: string) => void onDelete: (id: string) => void onCloseTab: (id: string) => void + onHandoff: (id: string) => void } -function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTab }: SessionRowProps) { +function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTab, onHandoff }: SessionRowProps) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(session.name) const [userPicked, setUserPicked] = useState(undefined) @@ -398,12 +434,41 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa ]} /> )} - {created ? {(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created} : null} + {created + ? ( + + {(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''} + {created} + + ) + : null} + {session.handedOffTo + ? ( + + · 已交 + {' '} + {session.handedOffTo} + + ) + : null}
)} + + {session.tabOpen && (