✨ feat(vscode): 会话 tab 可交接对话给同事

This commit is contained in:
cruldra committed 2026-08-27 15:14:42 +08:00
1 parent 5ee8226167
commit 77037924ef
20 files changed
+938 -26

No files matched your search

@@ -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"