看板范围默认从「只看我的」改为「团队全部」,同事移交或推进的工单不用 手动切开关就能看见。同事的卡片按 Gitea 归属(assignee 优先,无 assignee 退回创建者)判定为只读:禁止拖列、开会话、实施、合并分支、删 worktree、 删改工单,卡片右上角贴归属人头像,详情面板属性表整体消毒并显示只读徽章。 看 PR 改动、勾已查看、生成文件摘要属于本机审查动作,照常放行。 扩展侧在消息分发前统一拦截需要归属人身份的消息,兜住前端快照过期或工单 刚被重新指派的竞态;webhook 单卡上板时同步归属,避免增量卡漏进门禁。 头像由扩展侧带 token 抓回转 data URI 下发,绕开 webview CSP 对外链图片 的限制,按 URL 进程内缓存。 Claude-Session: https://claude.ai/code/session_01KSgcQwf1mWrb8qeTKFvJbV
237 lines
9.7 KiB
TypeScript
237 lines
9.7 KiB
TypeScript
/**
|
||
* 底部 panel:左侧垂直 tab 切换四个内容区。
|
||
*
|
||
* - tab 1 「工单」:现有 IssueDetailPanel(基于 PropertyGrid)
|
||
* - tab 2 「Profile」:工作区 KV 表 ProfileGrid
|
||
* - tab 3 「会话」:ManagedSessionsPanel
|
||
* - tab 4 「改动」:PrFilesPanel
|
||
*
|
||
* 四个内容始终挂载,非当前用 hidden 隐藏,避免切 tab 卸载导致阅读态(选中文件、
|
||
* 树滚动/折叠、diff scroll)丢失。tab 状态用 useState,无持久化;激活态加左侧 2px 高亮条。
|
||
*/
|
||
|
||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||
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'
|
||
import { IssueDetailPanel } from './IssueDetailPanel'
|
||
import { ManagedSessionsPanel } from './ManagedSessionsPanel'
|
||
import { PrFilesPanel } from './PrFilesPanel'
|
||
import { ProfileGrid } from './ProfileGrid'
|
||
|
||
type TabKey = 'issue' | 'profile' | 'sessions' | 'changes'
|
||
|
||
interface BottomTabsProps {
|
||
// Issue tab props (pass-through to IssueDetailPanel)
|
||
issue: Issue | null
|
||
allIssues: Issue[]
|
||
globalAutoReview: boolean
|
||
onOpenInBrowser: (url: string) => void
|
||
onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
onStartTestSession: (issueNumber: number) => void
|
||
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
onOpenFile: (path: string) => void
|
||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||
onOpenPr: (pr: string) => void
|
||
onGeneratePrDiffSummary: (issueNumber: number) => void
|
||
isPrDiffSummaryRunning: (issueNumber: number) => boolean
|
||
onOpenWorktree: (path: string) => void
|
||
onDeleteWorktree: (issueNumber: number, path: string) => void
|
||
onMergeBranch: (issueNumber: number, branch: string) => void
|
||
onDeleteIssue?: (issueNumber: number) => void
|
||
onCloseIssue: (issueNumber: number) => void
|
||
onResetToTodo: (issueNumber: number) => void
|
||
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||
onContinueBrainstorm: (issueNumber: number) => void
|
||
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
||
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
|
||
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
||
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||
onOpenLogs: () => void
|
||
me?: string
|
||
/** Gitea login → 头像 data URI。 */
|
||
avatars?: Record<string, string>
|
||
onStartHandoff: (issueNumber: number) => void
|
||
onAcceptHandoff: (issueNumber: number) => void
|
||
isHandoffRunning: (issueNumber: number) => boolean
|
||
|
||
// Profile tab props
|
||
profileData: ProfilesData
|
||
onProfileSave: (next: ProfilesData) => void
|
||
onProfileOpen: (value: string) => void
|
||
|
||
// 会话管理 tab props
|
||
managedSessions: ManagedSessionsData
|
||
profiles: ClaudeProfile[]
|
||
onManagedSessionCreate: (profilePath: string, name?: string, prompt?: string) => void
|
||
onManagedSessionRename: (id: string, name: string) => void
|
||
onManagedSessionResume: (id: string, profilePath?: string) => void
|
||
onManagedSessionDelete: (id: string) => void
|
||
onManagedSessionCloseTab: (id: string) => void
|
||
importableSessions: Array<{ id: string, name: string, mtimeMs: number }>
|
||
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) {
|
||
const [tab, setTab] = useState<TabKey>('issue')
|
||
|
||
return (
|
||
<div className="flex h-full w-full bg-[var(--vscode-editor-background)]">
|
||
{/* 左侧垂直 tab 栏 */}
|
||
<div className="flex w-9 shrink-0 flex-col items-stretch border-r border-[var(--vscode-panel-border)] bg-[var(--vscode-sideBar-background,var(--vscode-editor-background))]">
|
||
<TabButton
|
||
active={tab === 'issue'}
|
||
onClick={() => setTab('issue')}
|
||
icon={<ClipboardList className="size-4" />}
|
||
title="工单"
|
||
/>
|
||
<TabButton
|
||
active={tab === 'profile'}
|
||
onClick={() => setTab('profile')}
|
||
icon={<Layers className="size-4" />}
|
||
title="Profile"
|
||
/>
|
||
<TabButton
|
||
active={tab === 'sessions'}
|
||
onClick={() => setTab('sessions')}
|
||
icon={<MessagesSquare className="size-4" />}
|
||
title="会话"
|
||
badge={props.pendingHandoffs.length}
|
||
/>
|
||
<TabButton
|
||
active={tab === 'changes'}
|
||
onClick={() => setTab('changes')}
|
||
icon={<FileDiff className="size-4" />}
|
||
title="改动"
|
||
/>
|
||
</div>
|
||
|
||
{/* 右侧内容:始终挂载,非当前 hidden,保留阅读态 */}
|
||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||
<div
|
||
className={`h-full w-full ${tab === 'issue' ? '' : 'hidden'}`}
|
||
aria-hidden={tab !== 'issue'}
|
||
inert={tab !== 'issue' ? true : undefined}
|
||
>
|
||
<IssueDetailPanel
|
||
issue={props.issue}
|
||
allIssues={props.allIssues}
|
||
globalAutoReview={props.globalAutoReview}
|
||
onOpenInBrowser={props.onOpenInBrowser}
|
||
onResumeSession={props.onResumeSession}
|
||
onResumeReviewSession={props.onResumeReviewSession}
|
||
onStartTestSession={props.onStartTestSession}
|
||
onResumeTestSession={props.onResumeTestSession}
|
||
onOpenFile={props.onOpenFile}
|
||
onImplement={props.onImplement}
|
||
onOpenPr={props.onOpenPr}
|
||
onGeneratePrDiffSummary={props.onGeneratePrDiffSummary}
|
||
isPrDiffSummaryRunning={props.isPrDiffSummaryRunning}
|
||
onOpenWorktree={props.onOpenWorktree}
|
||
onDeleteWorktree={props.onDeleteWorktree}
|
||
onMergeBranch={props.onMergeBranch}
|
||
onDeleteIssue={props.onDeleteIssue}
|
||
onCloseIssue={props.onCloseIssue}
|
||
onResetToTodo={props.onResetToTodo}
|
||
onCloseSessionTab={props.onCloseSessionTab}
|
||
onContinueBrainstorm={props.onContinueBrainstorm}
|
||
onUpdateAutoReview={props.onUpdateAutoReview}
|
||
onUpdateProfilePath={props.onUpdateProfilePath}
|
||
onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath}
|
||
onUpdateTestProfilePath={props.onUpdateTestProfilePath}
|
||
profiles={props.profiles}
|
||
onOpenLogs={props.onOpenLogs}
|
||
me={props.me}
|
||
avatars={props.avatars}
|
||
onStartHandoff={props.onStartHandoff}
|
||
onAcceptHandoff={props.onAcceptHandoff}
|
||
isHandoffRunning={props.isHandoffRunning}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={`h-full w-full ${tab === 'profile' ? '' : 'hidden'}`}
|
||
aria-hidden={tab !== 'profile'}
|
||
inert={tab !== 'profile' ? true : undefined}
|
||
>
|
||
<ProfileGrid
|
||
data={props.profileData}
|
||
onSave={props.onProfileSave}
|
||
onOpen={props.onProfileOpen}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={`h-full w-full ${tab === 'sessions' ? '' : 'hidden'}`}
|
||
aria-hidden={tab !== 'sessions'}
|
||
inert={tab !== 'sessions' ? true : undefined}
|
||
>
|
||
<ManagedSessionsPanel
|
||
data={props.managedSessions}
|
||
profiles={props.profiles}
|
||
onCreate={props.onManagedSessionCreate}
|
||
onRename={props.onManagedSessionRename}
|
||
onResume={props.onManagedSessionResume}
|
||
onDelete={props.onManagedSessionDelete}
|
||
onCloseTab={props.onManagedSessionCloseTab}
|
||
importableSessions={props.importableSessions}
|
||
importableLoading={props.importableLoading}
|
||
onListImportable={props.onListImportableSessions}
|
||
onImport={props.onImportNamedSession}
|
||
pendingHandoffs={props.pendingHandoffs}
|
||
onHandoff={props.onSessionHandoff}
|
||
onAcceptHandoff={props.onAcceptSessionHandoff}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={`h-full w-full ${tab === 'changes' ? '' : 'hidden'}`}
|
||
aria-hidden={tab !== 'changes'}
|
||
inert={tab !== 'changes' ? true : undefined}
|
||
>
|
||
<PrFilesPanel issue={props.issue} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface TabButtonProps {
|
||
active: boolean
|
||
onClick: () => void
|
||
icon: React.ReactNode
|
||
title: string
|
||
badge?: number
|
||
}
|
||
|
||
function TabButton({ active, onClick, icon, title, badge }: TabButtonProps) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
title={title}
|
||
aria-label={title}
|
||
aria-pressed={active}
|
||
className={`relative flex h-9 w-full items-center justify-center transition-colors ${
|
||
active
|
||
? 'text-[var(--vscode-foreground)]'
|
||
: 'text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]'
|
||
}`}
|
||
>
|
||
{active && (
|
||
<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>
|
||
)
|
||
}
|