✨ feat(vscode): 会话 tab 可交接对话给同事
This commit is contained in:
@@ -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