✨ feat(vscode): 会话 tab 可交接对话给同事
This commit is contained in:
@@ -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 = "<!-- spx:session-mailbox -->"
|
||||
|
||||
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.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 = '<!-- spx:session-mailbox -->'
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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 }],
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ export async function listIssuesByFilter(opts: {
|
||||
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
|
||||
filter?: 'assigned_by' | 'created_by'
|
||||
user?: string
|
||||
/** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */
|
||||
labels?: string
|
||||
}): Promise<GiteaIssue[]> {
|
||||
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<GiteaLabel[]> {
|
||||
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<GiteaLabel> {
|
||||
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<GiteaIssue> {
|
||||
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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<number | undefined> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Manage
|
||||
const name = typeof s?.name === 'string' ? s.name : ''
|
||||
const profilePath = typeof s?.profilePath === 'string' ? s.profilePath : undefined
|
||||
const createdAt = typeof s?.createdAt === 'number' ? s.createdAt : 0
|
||||
return { id, name, profilePath, createdAt }
|
||||
const handedOffTo = typeof s?.handedOffTo === 'string' && s.handedOffTo.length > 0 ? s.handedOffTo : undefined
|
||||
const handedOffAt = typeof s?.handedOffAt === 'number' ? s.handedOffAt : undefined
|
||||
return { id, name, profilePath, createdAt, handedOffTo, handedOffAt }
|
||||
})
|
||||
.filter(s => s.id !== '')
|
||||
: []
|
||||
|
||||
@@ -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(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/i)
|
||||
const nonce = nonceMatch ? nonceMatch[1] : null
|
||||
// Promote the in-flight brainstorm terminal into the panel's
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const [handoffSession, setHandoffSession] = useState<{ id: string, name: string } | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,14 +480,29 @@ export function App() {
|
||||
/>
|
||||
<HandoffModal
|
||||
open={handoffIssue !== null}
|
||||
issueNumber={handoffIssue}
|
||||
title={handoffIssue !== null ? `移交 #${handoffIssue}` : '移交'}
|
||||
description="会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。"
|
||||
users={handoffUsers && handoffUsers.issueNumber === handoffIssue ? handoffUsers.users : null}
|
||||
onCancel={() => setHandoffIssue(null)}
|
||||
onSubmit={(n, to) => {
|
||||
startHandoff(n, to)
|
||||
onSubmit={(to) => {
|
||||
if (handoffIssue !== null)
|
||||
startHandoff(handoffIssue, to)
|
||||
setHandoffIssue(null)
|
||||
}}
|
||||
/>
|
||||
<HandoffModal
|
||||
open={handoffSession !== null}
|
||||
title={handoffSession ? `交接「${handoffSession.name}」` : '交接'}
|
||||
description="把这段对话打包发给同事。不改 git、不上看板。对方在会话 tab 点接收后即可 resume。双方都能继续聊,对话会分叉。"
|
||||
confirmLabel="交接"
|
||||
users={handoffSession ? sessionHandoffUsers : null}
|
||||
onCancel={() => setHandoffSession(null)}
|
||||
onSubmit={(to) => {
|
||||
if (handoffSession)
|
||||
submitSessionHandoff(handoffSession.id, to)
|
||||
setHandoffSession(null)
|
||||
}}
|
||||
/>
|
||||
<LogModal
|
||||
open={showLogs}
|
||||
entries={logs}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||
import type { ProfilesData } from '../lib/messages'
|
||||
import type { ProfilesData, SessionHandoffPendingItem } from '../lib/messages'
|
||||
import type { Issue, ManagedSessionsData } from '../types'
|
||||
import { ClipboardList, FileDiff, Layers, MessagesSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
@@ -72,6 +72,9 @@ interface BottomTabsProps {
|
||||
importableLoading: boolean
|
||||
onListImportableSessions: () => 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={<MessagesSquare className="size-4" />}
|
||||
title="会话"
|
||||
badge={props.pendingHandoffs.length}
|
||||
/>
|
||||
<TabButton
|
||||
active={tab === 'changes'}
|
||||
@@ -176,6 +180,9 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
importableLoading={props.importableLoading}
|
||||
onListImportable={props.onListImportableSessions}
|
||||
onImport={props.onImportNamedSession}
|
||||
pendingHandoffs={props.pendingHandoffs}
|
||||
onHandoff={props.onSessionHandoff}
|
||||
onAcceptHandoff={props.onAcceptSessionHandoff}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -195,9 +202,10 @@ interface TabButtonProps {
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
badge?: number
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, icon, title }: TabButtonProps) {
|
||||
function TabButton({ active, onClick, icon, title, badge }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -215,6 +223,11 @@ function TabButton({ active, onClick, icon, title }: TabButtonProps) {
|
||||
<span className="absolute left-0 top-0 h-full w-[2px] bg-[var(--vscode-focusBorder)]" />
|
||||
)}
|
||||
{icon}
|
||||
{badge != null && badge > 0 && (
|
||||
<span className="absolute right-0.5 top-0.5 min-w-[14px] rounded-full bg-[var(--vscode-activityBarBadge-background)] px-0.5 text-center text-[9px] leading-[14px] text-[var(--vscode-activityBarBadge-foreground)]">
|
||||
{badge > 9 ? '9+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string>('')
|
||||
|
||||
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
|
||||
>
|
||||
<div className="mb-3 flex items-center">
|
||||
<h2 className="flex-1 font-medium">
|
||||
移交 #
|
||||
{issueNumber}
|
||||
{title}
|
||||
</h2>
|
||||
<button type="button" onClick={onCancel} aria-label="关闭" className="rounded p-1 hover:bg-white/10">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-xs opacity-70">
|
||||
会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。
|
||||
{description}
|
||||
</p>
|
||||
{users === null
|
||||
? (
|
||||
@@ -77,10 +77,10 @@ export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: P
|
||||
<button
|
||||
type="button"
|
||||
disabled={!to}
|
||||
onClick={() => onSubmit(issueNumber, to)}
|
||||
onClick={() => onSubmit(to)}
|
||||
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] disabled:opacity-50"
|
||||
>
|
||||
移交
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string>('')
|
||||
const [name, setName] = useState<string>('')
|
||||
@@ -242,6 +249,33 @@ export function ManagedSessionsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingHandoffs.length > 0 && (
|
||||
<div className="shrink-0 border-b border-[var(--vscode-panel-border)] p-2">
|
||||
<div className="mb-1 text-[10px] text-[var(--vscode-descriptionForeground)]">待接收</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pendingHandoffs.map(p => (
|
||||
<li key={p.attachmentId} className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs">{p.name || p.sid.slice(0, 8)}</div>
|
||||
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||
来自
|
||||
{' '}
|
||||
{p.from}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcceptHandoff(p.attachmentId)}
|
||||
className="shrink-0 rounded bg-[var(--vscode-button-background)] px-2 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
|
||||
>
|
||||
接收
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下方会话列表 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{sessions.length === 0
|
||||
@@ -261,6 +295,7 @@ export function ManagedSessionsPanel({
|
||||
onResume={onResume}
|
||||
onDelete={onDelete}
|
||||
onCloseTab={onCloseTab}
|
||||
onHandoff={onHandoff}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -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<string | undefined>(undefined)
|
||||
@@ -398,12 +434,41 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{created ? <span className="shrink-0">{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created}</span> : null}
|
||||
{created
|
||||
? (
|
||||
<span className="shrink-0">
|
||||
{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}
|
||||
{created}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
{session.handedOffTo
|
||||
? (
|
||||
<span className="ml-1 shrink-0 text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||
· 已交
|
||||
{' '}
|
||||
{session.handedOffTo}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onHandoff(session.id)
|
||||
}}
|
||||
title="交接给同事"
|
||||
aria-label="交接给同事"
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)] group-hover:opacity-70"
|
||||
>
|
||||
<UserRoundPlus className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{session.tabOpen && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 都把动作委托给主进程,列表由后续 `managed-sessions/show` 更新
|
||||
*/
|
||||
|
||||
import type { SessionHandoffPendingItem } from '../lib/messages'
|
||||
import type { ManagedSessionsData } from '../types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { onMessage, postMessage } from '../lib/vscode'
|
||||
@@ -29,12 +30,20 @@ export interface UseManagedSessionsResult {
|
||||
importNamedSession: (sessionId: string, name: string) => void
|
||||
importableSessions: ImportableNamedSession[]
|
||||
importableLoading: boolean
|
||||
pendingHandoffs: SessionHandoffPendingItem[]
|
||||
sessionHandoffUsers: string[] | null
|
||||
startSessionHandoff: (sessionId: string) => void
|
||||
submitSessionHandoff: (sessionId: string, to: string) => void
|
||||
acceptSessionHandoff: (attachmentId: number) => void
|
||||
refreshSessionHandoffs: () => void
|
||||
}
|
||||
|
||||
export function useManagedSessions(): UseManagedSessionsResult {
|
||||
const [managedSessions, setManagedSessions] = useState<ManagedSessionsData>({ sessions: [] })
|
||||
const [importableSessions, setImportableSessions] = useState<ImportableNamedSession[]>([])
|
||||
const [importableLoading, setImportableLoading] = useState(false)
|
||||
const [pendingHandoffs, setPendingHandoffs] = useState<SessionHandoffPendingItem[]>([])
|
||||
const [sessionHandoffUsers, setSessionHandoffUsers] = useState<string[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = onMessage((msg) => {
|
||||
@@ -44,8 +53,13 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
setImportableSessions(msg.sessions)
|
||||
setImportableLoading(false)
|
||||
}
|
||||
if (msg.type === 'session-handoff/pending')
|
||||
setPendingHandoffs(msg.items)
|
||||
if (msg.type === 'session-handoff/users-result')
|
||||
setSessionHandoffUsers(msg.users)
|
||||
})
|
||||
postMessage({ type: 'managed-sessions/get' })
|
||||
postMessage({ type: 'session-handoff/refresh' })
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
@@ -79,6 +93,23 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
postMessage({ type: 'managed-sessions/import', sessionId, name })
|
||||
}, [])
|
||||
|
||||
const startSessionHandoff = useCallback((_sessionId: string): void => {
|
||||
setSessionHandoffUsers(null)
|
||||
postMessage({ type: 'session-handoff/users' })
|
||||
}, [])
|
||||
|
||||
const submitSessionHandoff = useCallback((sessionId: string, to: string): void => {
|
||||
postMessage({ type: 'session-handoff/start', sessionId, to })
|
||||
}, [])
|
||||
|
||||
const acceptSessionHandoff = useCallback((attachmentId: number): void => {
|
||||
postMessage({ type: 'session-handoff/accept', attachmentId })
|
||||
}, [])
|
||||
|
||||
const refreshSessionHandoffs = useCallback((): void => {
|
||||
postMessage({ type: 'session-handoff/refresh' })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
managedSessions,
|
||||
createManagedSession,
|
||||
@@ -90,5 +121,11 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
importNamedSession,
|
||||
importableSessions,
|
||||
importableLoading,
|
||||
pendingHandoffs,
|
||||
sessionHandoffUsers,
|
||||
startSessionHandoff,
|
||||
submitSessionHandoff,
|
||||
acceptSessionHandoff,
|
||||
refreshSessionHandoffs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ export interface PrFile {
|
||||
|
||||
export type ToastLevel = 'info' | 'success' | 'error'
|
||||
|
||||
export interface SessionHandoffPendingItem {
|
||||
attachmentId: number
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the extension-side LogEntry in src/logging/logger.ts.
|
||||
* Kept manually in sync — workspace boundaries prevent direct import.
|
||||
@@ -60,6 +68,9 @@ export type ExtensionToWebview
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
|
||||
| { type: 'session-handoff/users-result', users: string[] }
|
||||
| { type: 'session-handoff/pending', items: SessionHandoffPendingItem[] }
|
||||
| { type: 'session-handoff/done', sessionId: string }
|
||||
| { type: 'handoff/done', issueNumber: number }
|
||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||
| { type: 'issue/select-by-number', issueNumber: number }
|
||||
@@ -188,6 +199,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 }
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface ManagedSession {
|
||||
name: string
|
||||
profilePath?: string
|
||||
createdAt: number
|
||||
handedOffTo?: string
|
||||
handedOffAt?: number
|
||||
/** Transient:该会话的终端 tab 是否正开着(仅 show payload 附带,不持久化)。 */
|
||||
tabOpen?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user