- prototypeFile 走通 state → Issue → webview 全链路(gitea/youtrack 两个
loader、两份 KNOWN_STATE_FIELDS、issue/patch、webview types),按 planFile
同款当共享字段;路径校验换成 .json 版,允许绝对路径
- 详情面板「计划文件」下加「原型插件路径」行:双击开 manifest.json,
行尾按钮「在 Figma 中导入并打开」;没挂插件时只显示空态
- src/figma/:把 manifest 写进 Figma 桌面版的 localFileExtensions 再拉起
Figma。坑是 Figma 退出会用内存状态覆盖 settings.json,所以写之前先
osascript 优雅退出并轮询等它真没了(10s 等不到就让用户手动关,不强杀);
写前备份 settings.json.bak-<时间戳>;已导入过就跳过写入直接打开;
非 macOS 只把路径复制到剪贴板
- webhook 里 spec/plan 的 marker 同步改成表驱动,顺带认第三种 prototype
- 提示词:prototype.md 要求画完挂 marker;brainstorm-continue.md 新增
{prototypeRef} 占位符,有原型插件时让续聊先读它别从零重画,没有时
连同整段抹掉
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
251 lines
11 KiB
TypeScript
251 lines
11 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
|
||
isSessionTabPending: (sessionId: string) => boolean
|
||
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
onStartReviewSession: (issueNumber: number) => void
|
||
onStartTestSession: (issueNumber: number) => void
|
||
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
onStartPrototypeSession: (issueNumber: number) => void
|
||
onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
onOpenFile: (path: string) => void
|
||
onOpenPrototypeInFigma: (issueNumber: number, 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' | 'prototype') => void
|
||
onContinueBrainstorm: (issueNumber: number) => void
|
||
onClearBrainstormSession: (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
|
||
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: 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}
|
||
isSessionTabPending={props.isSessionTabPending}
|
||
onResumeReviewSession={props.onResumeReviewSession}
|
||
onStartReviewSession={props.onStartReviewSession}
|
||
onStartTestSession={props.onStartTestSession}
|
||
onResumeTestSession={props.onResumeTestSession}
|
||
onStartPrototypeSession={props.onStartPrototypeSession}
|
||
onResumePrototypeSession={props.onResumePrototypeSession}
|
||
onOpenFile={props.onOpenFile}
|
||
onOpenPrototypeInFigma={props.onOpenPrototypeInFigma}
|
||
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}
|
||
onClearBrainstormSession={props.onClearBrainstormSession}
|
||
onUpdateAutoReview={props.onUpdateAutoReview}
|
||
onUpdateProfilePath={props.onUpdateProfilePath}
|
||
onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath}
|
||
onUpdateTestProfilePath={props.onUpdateTestProfilePath}
|
||
onUpdatePrototypeProfilePath={props.onUpdatePrototypeProfilePath}
|
||
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>
|
||
)
|
||
}
|