Files
superwork/vscode/webview-ui/src/components/BottomTabs.tsx
T
cruldra 9167b86708 ✨ feat(vscode): 看板默认显示团队工单,同事的卡只读并贴头像
看板范围默认从「只看我的」改为「团队全部」,同事移交或推进的工单不用
手动切开关就能看见。同事的卡片按 Gitea 归属(assignee 优先,无 assignee
退回创建者)判定为只读:禁止拖列、开会话、实施、合并分支、删 worktree、
删改工单,卡片右上角贴归属人头像,详情面板属性表整体消毒并显示只读徽章。
看 PR 改动、勾已查看、生成文件摘要属于本机审查动作,照常放行。

扩展侧在消息分发前统一拦截需要归属人身份的消息,兜住前端快照过期或工单
刚被重新指派的竞态;webhook 单卡上板时同步归属,避免增量卡漏进门禁。
头像由扩展侧带 token 抓回转 data URI 下发,绕开 webview CSP 对外链图片
的限制,按 URL 进程内缓存。

Claude-Session: https://claude.ai/code/session_01KSgcQwf1mWrb8qeTKFvJbV
2026-09-10 15:30:02 +08:00

237 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 底部 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>
)
}