Files
superwork/vscode/webview-ui/src/components/IssueDetailPanel.tsx
T

639 lines
29 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.
import type { ReactNode } from 'react'
import { useMemo, useRef } from 'react'
import { CircleSlash, ExternalLink, GitMerge, Play, Terminal, Trash2, X } from 'lucide-react'
import type { ClaudeProfile } from '../hooks/useIssues'
import type { Issue, IssueColumn } from '../types'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { isIssueLocked } from '../lib/dependencies'
import type { PropertyGroup } from './property-grid'
import { PropertyGrid } from './property-grid'
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
function matchProfileOption(
current: string | undefined,
profiles: ClaudeProfile[],
): string {
const raw = current?.trim()
if (!raw)
return ''
if (profiles.some(p => p.path === raw))
return raw
const base = raw.split(/[/\\]/).pop() ?? ''
if (!base)
return raw
const byFile = profiles.find(p => p.path.split(/[/\\]/).pop() === base)
if (byFile)
return byFile.path
const name = base.endsWith('.json') ? base.slice(0, -'.json'.length) : base
const byName = profiles.find(p => p.name === name)
return byName?.path ?? raw
}
interface IssueDetailPanelProps {
issue: Issue | null
/** 当前所有 issues,用于解析前置依赖锁定态。 */
allIssues: Issue[]
/** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */
globalAutoReview: boolean
onOpenInBrowser: (url: string) => void
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
/** Open a new terminal that runs `codex resume <id>` for the auto-review
* conversation associated with this issue. */
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
/** Open a new terminal that runs `claude --resume <id>` for the test
* conversation associated with this issue. */
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
/** Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the
* Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). */
onStartTestSession: (issueNumber: number) => void
/** Open a workspace-relative file in the editor. */
onOpenFile: (path: string) => void
/** Kick off the end-to-end implementation flow for the given plan file. */
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
/** Open the gitea PR page in the browser. */
onOpenPr: (pr: string) => void
/** Start a background Claude run that writes the PR diff summary markdown. */
onGeneratePrDiffSummary: (issueNumber: number) => void
/** Whether a PR diff summary generation request is in flight for an issue. */
isPrDiffSummaryRunning: (issueNumber: number) => boolean
/** Open the workspace-relative worktree path in a new VS Code window. */
onOpenWorktree: (path: string) => void
/** Delete the worktree (`git worktree remove`) and clear it from state. */
onDeleteWorktree: (issueNumber: number, path: string) => void
/** Pre-merge the issue's feature branch into the main worktree's current branch
* (`git merge --no-commit --no-ff <branch>`) so the user can inspect locally. */
onMergeBranch: (issueNumber: number, branch: string) => void
/** 硬删整个工单 + 关联资源(worktree / PR / feature branch / cc tabs)。
* 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。 */
onDeleteIssue?: (issueNumber: number) => void
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
onCloseIssue: (issueNumber: number) => void
/** Close the matching session terminal tab. The extension watches
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
* makes the X button vanish on its own. */
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
/** Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty
* (created out-of-band via spx without webhook linking). Bound to the Play
* button next to the "头脑风暴会话 id" row. */
onStartBrainstormSession: (issueNumber: number) => void
/** Persist a per-issue `autoReview` override into the state JSON. */
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
/** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */
profiles: ClaudeProfile[]
/** Persist a per-issue `profilePath` override into the state JSON. */
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
/** Persist a per-issue `brainstormProfilePath` override into the state JSON. */
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
/** Persist a per-issue `testProfilePath` override into the state JSON. */
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
/** Open the in-webview log modal. */
onOpenLogs: () => void
}
/**
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
*
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
* 一个 action 类型字段,双击或选中卡按 Enter 都会触发
* `claude --resume <id>` 在新终端中恢复对话。
*/
export function IssueDetailPanel({
issue,
allIssues,
globalAutoReview,
onOpenInBrowser,
onResumeSession,
onResumeReviewSession,
onResumeTestSession,
onStartTestSession,
onOpenFile,
onImplement,
onOpenPr,
onGeneratePrDiffSummary,
isPrDiffSummaryRunning,
onOpenWorktree,
onDeleteWorktree,
onMergeBranch,
onDeleteIssue,
onCloseIssue,
onCloseSessionTab,
onStartBrainstormSession,
onUpdateAutoReview,
profiles,
onUpdateProfilePath,
onUpdateBrainstormProfilePath,
onUpdateTestProfilePath,
onOpenLogs,
}: IssueDetailPanelProps) {
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
const { locked, prerequisiteNumber } = issue
? isIssueLocked(issue, allIssues)
: { locked: false, prerequisiteNumber: undefined as number | undefined }
const lockTitle = locked && prerequisiteNumber != null
? `等待 #${prerequisiteNumber} 完成`
: undefined
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表,
// 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
const RESUME_THROTTLE_MS = 800
const throttleBySessionId = (sid: string): boolean => {
const now = Date.now()
const last = lastFiredAtRef.current.get(sid) ?? 0
if (now - last < RESUME_THROTTLE_MS)
return false
lastFiredAtRef.current.set(sid, now)
return true
}
const schema = useMemo<PropertyGroup[]>(
() => [
{
id: 'state',
label: 'state JSON',
actions: (
<button
type="button"
onClick={onOpenLogs}
title="查看扩展日志"
aria-label="查看扩展日志"
className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
>
<Terminal className="size-3.5" />
</button>
),
properties: [
{
key: 'column',
label: '状态',
type: 'select',
readOnly: true,
description: '工单所在的看板列;写入 issue 最后一条 JSON 评论的 column 字段',
options: COLUMN_ORDER.map((c: IssueColumn) => ({
label: `${c}${COLUMN_LABELS[c]}`,
value: c,
})),
},
(() => {
// 头脑风暴会话 id 行有三种状态:
// 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab
// 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮)
// 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab)
// 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
const canStart = !hasSession && issue?.column !== 'done'
let secondaryActionIcon: ReactNode | undefined
let secondaryActionTitle: string | undefined
let onSecondaryAction: (() => void) | undefined
if (hasSession && issue?.brainstormTabOpen) {
secondaryActionIcon = <X className="size-3.5" />
secondaryActionTitle = '关闭头脑风暴 tab'
onSecondaryAction = () => {
if (issue)
onCloseSessionTab(issue.number, 'brainstorm')
}
}
else if (canStart) {
secondaryActionIcon = <Play className="size-3.5" />
secondaryActionTitle = '启动头脑风暴会话'
onSecondaryAction = () => {
if (issue)
onStartBrainstormSession(issue.number)
}
}
return {
key: 'sessionId',
label: '头脑风暴会话id',
type: 'action' as const,
description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复对话'
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0)
return
if (!throttleBySessionId(v))
return
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
},
secondaryActionIcon,
secondaryActionTitle,
onSecondaryAction,
}
})(),
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
// 给出不同的提示文案。
{
key: 'implementSessionId',
label: '实施会话id',
type: 'action',
description: issue?.worktreeExists
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree'
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v) => {
if (typeof v !== 'string' || v.length === 0)
return
if (!throttleBySessionId(v))
return
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
},
secondaryActionIcon: issue?.implementTabOpen
? <X className="size-3.5" />
: undefined,
secondaryActionTitle: '关闭实施 tab',
onSecondaryAction: () => {
if (issue)
onCloseSessionTab(issue.number, 'implement')
},
},
issue?.worktreeExists
? {
key: 'reviewSessionId',
label: '审查会话id',
type: 'action',
description: '双击在新终端运行 codex resume <id> 查看审查会话(cwd 优先用 worktree',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v) => {
if (typeof v !== 'string' || v.length === 0 || !issue)
return
if (!throttleBySessionId(v))
return
onResumeReviewSession(v, issue.number, issue?.worktreePath)
},
secondaryActionIcon: issue?.reviewTabOpen
? <X className="size-3.5" />
: undefined,
secondaryActionTitle: '关闭审查 tab',
onSecondaryAction: () => {
if (issue)
onCloseSessionTab(issue.number, 'review')
},
}
: {
key: 'reviewSessionId',
label: '审查会话id',
type: 'string',
readOnly: true,
description: 'worktree 已清理,无法 resume;仅保留 id 文本',
},
(() => {
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
// 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
// 2. testSessionId 已存在 + testTabOpen=false → 显示 id,双击 resume
// 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
// prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
const canStart = !hasSession && !!issue?.pr
let secondaryActionIcon: ReactNode | undefined
let secondaryActionTitle: string | undefined
let onSecondaryAction: (() => void) | undefined
if (hasSession && issue?.testTabOpen) {
secondaryActionIcon = <X className="size-3.5" />
secondaryActionTitle = '关闭测试 tab'
onSecondaryAction = () => {
if (issue)
onCloseSessionTab(issue.number, 'test')
}
}
else if (canStart) {
secondaryActionIcon = <Play className="size-3.5" />
secondaryActionTitle = '启动测试会话'
onSecondaryAction = () => {
if (issue)
onStartTestSession(issue.number)
}
}
return {
key: 'testSessionId',
label: '测试会话id',
type: 'action' as const,
description: hasSession
? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0 || !issue)
return
if (!throttleBySessionId(v))
return
onResumeTestSession(v, issue.number)
},
secondaryActionIcon,
secondaryActionTitle,
onSecondaryAction,
}
})(),
{
key: 'autoReview',
label: '自动审查',
type: 'boolean',
description: `打开新 PR 时是否自动启动 codex 审查;未设置时跟随全局(当前全局:${globalAutoReview ? '开' : '关'}`,
},
{
key: 'color',
label: '颜色',
type: 'theme-color',
readOnly: true,
description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色',
},
{
key: 'brainstormProfilePath',
label: '头脑风暴配置文件',
type: 'select',
options: (() => {
// 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何
// option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发
// onChange、永远存不进去;占位项让未设置态有真实匹配项。
// 跨机绝对路径按 basename 对齐本机 profiles,避免「自定义:/Users/...」。
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
const current = matchProfileOption(issue?.brainstormProfilePath, profiles)
if (current && !opts.some(o => o.value === current))
opts.push({ label: `自定义:${current}`, value: current })
return opts
})(),
description: '头脑风暴会话使用的 Claude 配置文件;选「(默认)」用内置默认,与实施/测试互不回退',
},
{
key: 'profilePath',
label: '实施配置文件',
type: 'select',
options: (() => {
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
const current = matchProfileOption(issue?.profilePath, profiles)
if (current && !opts.some(o => o.value === current))
opts.push({ label: `自定义:${current}`, value: current })
return opts
})(),
description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/测试互不回退',
},
{
key: 'testProfilePath',
label: '测试配置文件',
type: 'select',
options: (() => {
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
const current = matchProfileOption(issue?.testProfilePath, profiles)
if (current && !opts.some(o => o.value === current))
opts.push({ label: `自定义:${current}`, value: current })
return opts
})(),
description: '测试会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/实施互不回退',
},
{
key: 'specFile',
label: '规格文件',
type: 'file-link',
description: '双击文件名在编辑器打开;cc 通过 issue body 的 <!-- spx:spec=路径 --> 注释实时同步',
onOpen: (p: string) => onOpenFile(p),
},
{
key: 'planFile',
label: '计划文件',
type: 'file-link',
description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
onOpen: (p: string) => onOpenFile(p),
// Hide the "实施" button entirely when there's no plan file.
secondaryActionIcon: issue?.planFile
? <Play className="size-3.5" />
: undefined,
secondaryActionTitle: locked ? lockTitle : '实施此计划',
// Disable while running or already done. The user can still click
// when status === 'failed' or never been run. Re-clicking while
// 'running' is blocked at the UI to avoid registering duplicate
// webhooks. To retry after a perceived failure with status still
// 'running', edit the state-JSON comment manually for now.
// 锁定态(前置工单未完成)同样禁止启动实施。
secondaryDisabled:
!issue?.planFile
|| issue?.implementStatus === 'running'
|| issue?.implementStatus === 'done'
|| locked,
onSecondaryAction: () => {
if (!issue?.planFile)
return
onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
},
},
{
key: 'pr',
label: '合并请求',
type: 'pr-link',
description: '双击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识',
onAction: (v) => {
if (typeof v !== 'string' || v.length === 0)
return
// value 形如 "65" 或 "65(已合并)";剥掉合并后缀只把纯编号传出去。
const pr = v.replace(/\(已合并\)\s*$/, '')
if (pr.length > 0)
onOpenPr(pr)
},
secondaryActionIcon: issue?.pr && !issue?.prDiffFile
? <Play className="size-3.5" />
: undefined,
secondaryActionTitle: prDiffSummaryRunning
? 'PR 变更摘要生成中'
: '生成 PR 变更摘要',
secondaryDisabled: prDiffSummaryRunning,
onSecondaryAction: () => {
if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
onGeneratePrDiffSummary(issue.number)
},
},
{
key: 'prDiffFile',
label: 'PR 变更摘要',
type: 'file-link',
description: '双击文件名在编辑器打开生成的 PR 代码变更摘要',
onOpen: (p: string) => onOpenFile(p),
},
{
key: 'branch',
label: '分支',
type: 'string',
readOnly: true,
description: '实施流程创建的分支名',
secondaryActionIcon: issue?.branch ? <GitMerge className="size-3.5" /> : undefined,
secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)',
onSecondaryAction: () => {
if (issue?.branch)
onMergeBranch(issue.number, issue.branch)
},
},
issue?.worktreeExists
? {
key: 'worktreePath',
label: '工作树',
type: 'file-link',
description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
onOpen: (p: string) => onOpenWorktree(p),
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
secondaryActionIcon: <Trash2 className="size-3.5" />,
secondaryActionTitle: '删除 worktree',
}
: {
key: 'worktreePath',
label: '工作树',
type: 'string',
readOnly: true,
description: '实施流程创建的 git worktree 路径(workspace 相对)',
},
],
},
],
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
)
const data = useMemo<Record<string, unknown> | null>(() => {
if (!issue)
return null
return {
column: issue.column,
sessionId: issue.sessionId ?? null,
implementSessionId: issue.implementSessionId ?? null,
reviewSessionId: issue.reviewSessionId ?? null,
testSessionId: issue.testSessionId ?? null,
autoReview: issue.autoReview ?? globalAutoReview,
color: issue.color ?? null,
// 展示/选中值按 basename 映射到本机 profiles,避免跨机绝对路径变成「自定义」项。
brainstormProfilePath: matchProfileOption(issue.brainstormProfilePath, profiles) || null,
profilePath: matchProfileOption(issue.profilePath, profiles) || null,
testProfilePath: matchProfileOption(issue.testProfilePath, profiles) || null,
specFile: issue.specFile ?? null,
planFile: issue.planFile ?? null,
prDiffFile: issue.prDiffFile ?? null,
pr: issue.pr
? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
: null,
branch: issue.branch ?? null,
worktreePath: issue.worktreePath ?? null,
}
}, [
issue,
globalAutoReview,
profiles,
])
if (!issue || !data) {
return (
<div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
按方向键或点击卡片查看详情
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col gap-3 border-t border-[var(--vscode-panel-border)] p-3">
<div className="flex items-center gap-3">
<h3 className="flex-1 truncate text-sm font-medium">
<span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
</span>
{issue.source === 'youtrack' && (
<span
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
title="此工单来自 YouTrack"
>
YouTrack
</span>
)}
{' '}
{issue.title}
{locked && prerequisiteNumber != null
? (
<span
className="ml-2 inline-flex items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 align-middle text-[10px] font-normal text-[var(--vscode-disabledForeground)]"
title={lockTitle}
>
等待 #
{prerequisiteNumber}
{' '}
完成
</span>
)
: null}
</h3>
<button
type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
</button>
{/* Close/delete write to gitea by number — hide for youtrack cards to
avoid acting on a gitea issue of the same number. */}
{issue.source !== 'youtrack' && (
<>
<button
type="button"
onClick={() => onCloseIssue(issue.number)}
title="关闭工单"
aria-label="关闭工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<CircleSlash className="size-3.5" />
</button>
<button
type="button"
onClick={() => issue && onDeleteIssue?.(issue.number)}
title="删除工单(不可撤销)"
aria-label="删除工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
{issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && (
<div className="flex flex-col gap-1 text-xs">
<span className="opacity-60">附件({issue.attachments.length}</span>
<div className="flex flex-wrap gap-2">
{issue.attachments.map(att => (
<button
key={att.url}
type="button"
onClick={() => onOpenInBrowser(att.url)}
title={att.name}
className="inline-flex max-w-[200px] items-center gap-1 truncate rounded border border-[var(--vscode-panel-border)] px-2 py-1 hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3 shrink-0" />
<span className="truncate">{att.name}</span>
</button>
))}
</div>
</div>
)}
<div className="min-h-0 flex-1">
<PropertyGrid
key={issue.number}
schema={schema}
data={data}
hideToolbar
fillHeight
onChange={(key, value) => {
if (key === 'autoReview' && issue && typeof value === 'boolean')
onUpdateAutoReview(issue.number, value)
if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
onUpdateBrainstormProfilePath(issue.number, value)
if (key === 'profilePath' && issue && typeof value === 'string')
onUpdateProfilePath(issue.number, value)
if (key === 'testProfilePath' && issue && typeof value === 'string')
onUpdateTestProfilePath(issue.number, value)
}}
/>
</div>
</div>
)
}