🐛 fix(vscode): 修好单个会话 tab 从看板点回时不反向选中所属工单

Claude-Session: https://claude.ai/code/session_01FAA5YEyR7fzWj7gYQiLSqy
This commit is contained in:
2026-08-26 13:42:30 +08:00
parent eee0170a63
commit 06fb5ecec2
4 changed files with 157 additions and 77 deletions
+23 -51
View File
@@ -33,6 +33,7 @@ import * as profileAssets from './handlers/profileAssets'
import * as sessions from './handlers/sessions'
import * as settings from './handlers/settings'
import * as terminals from './handlers/terminals'
import { focusedTerminal, FocusedTerminalTracker } from './handlers/terminalTabs'
import * as toolbar from './handlers/toolbar'
import * as worktree from './handlers/worktree'
import * as youtrackIssues from './handlers/youtrackIssues'
@@ -195,7 +196,7 @@ export class KanbanWebviewPanel {
gitStateDisposable: { dispose: () => void } | undefined
/**
* 最近一次 onDidChangeActiveTerminal 触发反选 webview 的时间戳(ms epoch)。
* 最近一次终端 tab 切换触发反选 webview 的时间戳(ms epoch)。
* `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个,
* 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。
*/
@@ -205,22 +206,11 @@ export class KanbanWebviewPanel {
lastReverseSelectIssueNumber = -1
/**
* 上一次被 `handleActiveTerminalChanged` 处理的 terminal 引用。同一 terminal
* 短时间内重复触发(OSC title 改写、shell prompt 重绘等导致的 onDidChangeTabs
* 误触)直接 noop,避免 webview 反复 setPendingSelectId 造成 UI 闪烁
* `onDidCloseTerminal` 中清掉该 ref。
* 当前有焦点的会话终端 tab。由 tab 事件推导「活动分组的活动 tab」再做 diff,
* 只有焦点真正落到另一个终端 tab 时才反向选中工单:OSC title 改写不会重复
* 触发,单 tab 分组从看板点回来(无 isActive / activeTerminal 变化)也能触发
*/
// internal: handler 模块访问
lastActiveTerminalRef: Terminal | undefined
/**
* 我们自己 `show()` 引发的 active terminal 变更会回声到
* `handleActiveTerminalChanged`,若再反向选中工单就会和正向(选中→聚焦)
* 形成死循环、CPU 飙升。handleSessionFocus show 前把目标终端记在这里,
* handleActiveTerminalChanged 命中即吞掉这一次回声(基于引用、不靠时间窗)。
*/
// internal: handler 模块访问
programmaticTabReveal: Terminal | undefined
private readonly focusedTerminalTracker = new FocusedTerminalTracker<Terminal>()
// internal: context 供 handler 模块访问
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
@@ -285,8 +275,6 @@ export class KanbanWebviewPanel {
const origin = this.terminalOrigin.get(closed)
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
this.untrackClosedTerminal(closed)
if (closed === this.lastActiveTerminalRef)
this.lastActiveTerminalRef = undefined
if (origin?.kind === 'implement' || origin?.kind === 'test')
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
@@ -294,39 +282,12 @@ export class KanbanWebviewPanel {
if (conflictMatch)
void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1]))
}),
// 用户 column 2 切换终端 tab 时,反向选中看板上对应的工单卡片。
window.onDidChangeActiveTerminal((terminal) => {
if (!terminal)
return
this.handleActiveTerminalChanged(terminal)
}),
// 补充监听 tab 切换事件:当 column 2 只有一个 terminal tab 时,
// 它始终是 active terminal`onDidChangeActiveTerminal` 不会触发;
// 但用户点击该 tab 仍会让它成为 active tab`onDidChangeTabs` 会以
// `changed` + `isActive=true` 的形式投递事件。两者并存时
// `handleActiveTerminalChanged` 内的 `lastReverseSelectAt` 时间窗
// 已能去重,不会回环放大。
window.tabGroups.onDidChangeTabs((e) => {
// e.opened 也要处理:新开的终端 tab(如双击会话 id 打开)走 opened,
// 不在 changed 里,且 show(preserveFocus) 不一定触发 onDidChangeActiveTerminal
// 否则新 tab 不会反向选中对应工单。
for (const tab of [...e.opened, ...e.changed]) {
if (!tab.isActive)
continue
if (!(tab.input instanceof TabInputTerminal))
continue
// TabInputTerminal 只有构造器、无任何字段,只能用 tab.label
// 反查 `window.terminals` 里的实例。终端 name 常被 shell 的
// OSC title 序列改写(追加 git branch 等后缀),所以双向 startsWith
// 兜底,参考 `injectIntoImplTerminal` 里的命名匹配策略。
const label = tab.label
const term = window.terminals.find(
t => label.startsWith(t.name) || t.name.startsWith(label),
)
if (term)
this.handleActiveTerminalChanged(term)
}
}),
// 用户把焦点切到 column 2 终端 tab 时,反向选中看板上对应的工单卡片。
// 会话终端一律开在编辑区(见 resolveTerminalLocation),tabs API 即唯一真相:
// 分组内切 tab 走 onDidChangeTabs,分组激活(单 tab 分组从看板点回来)
// 走 onDidChangeTabGroups,两者都只是触发一次重新推导。
window.tabGroups.onDidChangeTabs(() => this.syncFocusedTerminalTab()),
window.tabGroups.onDidChangeTabGroups(() => this.syncFocusedTerminalTab()),
)
// Start observing the workspace repo so we can hide the "提交代码"
@@ -335,6 +296,17 @@ export class KanbanWebviewPanel {
toolbar.setupGitWatcher(this)
}
private syncFocusedTerminalTab(): void {
const term = focusedTerminal(
window.tabGroups.activeTabGroup.activeTab,
input => input instanceof TabInputTerminal,
window.terminals,
)
const activated = this.focusedTerminalTracker.sync(term)
if (activated)
this.handleActiveTerminalChanged(activated)
}
static createOrShow(context: ExtensionContext): void {
if (KanbanWebviewPanel.current) {
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { findTerminalByTabLabel, focusedTerminal, FocusedTerminalTracker } from './terminalTabs'
const t371 = { name: 'issue-371-测试' }
const t257 = { name: 'issue-257-实施 (feature/billing)' }
const t37 = { name: 'issue-37-测试' }
const terminals = [t37, t371, t257]
describe('findTerminalByTabLabel', () => {
it('精确匹配优先', () => {
expect(findTerminalByTabLabel(terminals, 'issue-371-测试')).toBe(t371)
})
it('tab label 被 OSC title 追加后缀时按前缀匹配', () => {
expect(findTerminalByTabLabel(terminals, 'issue-371-测试 (main)')).toBe(t371)
})
it('terminal name 带后缀而 label 是原名时也能匹配', () => {
expect(findTerminalByTabLabel(terminals, 'issue-257-实施')).toBe(t257)
})
it('issue-37 不会误配 issue-371', () => {
expect(findTerminalByTabLabel(terminals, 'issue-37-测试')).toBe(t37)
})
it('找不到返回 undefined', () => {
expect(findTerminalByTabLabel(terminals, 'Project Dashboard')).toBeUndefined()
})
})
describe('focusedTerminal', () => {
const isTerminal = (input: unknown): boolean => input === 'terminal'
it('活动 tab 是终端 → 解析出对应终端', () => {
expect(focusedTerminal({ label: 'issue-371-测试', input: 'terminal' }, isTerminal, terminals)).toBe(t371)
})
it('活动 tab 不是终端(看板 webview)→ undefined', () => {
expect(focusedTerminal({ label: 'Superpowers Kanban', input: 'webview' }, isTerminal, terminals)).toBeUndefined()
})
it('没有活动 tab → undefined', () => {
expect(focusedTerminal(undefined, isTerminal, terminals)).toBeUndefined()
})
})
describe('focusedTerminalTracker', () => {
it('只开一个终端 tab:点回看板再点回该 tab 要再次触发', () => {
const tracker = new FocusedTerminalTracker<{ name: string }>()
expect(tracker.sync(t371)).toBe(t371)
expect(tracker.sync(undefined)).toBeUndefined()
expect(tracker.sync(t371)).toBe(t371)
})
it('同一终端重复上报(label 改写)不触发', () => {
const tracker = new FocusedTerminalTracker<{ name: string }>()
expect(tracker.sync(t371)).toBe(t371)
expect(tracker.sync(t371)).toBeUndefined()
expect(tracker.sync(t371)).toBeUndefined()
})
it('在终端之间切换触发新终端', () => {
const tracker = new FocusedTerminalTracker<{ name: string }>()
expect(tracker.sync(t371)).toBe(t371)
expect(tracker.sync(t257)).toBe(t257)
expect(tracker.sync(t371)).toBe(t371)
})
it('焦点离开终端不触发', () => {
const tracker = new FocusedTerminalTracker<{ name: string }>()
tracker.sync(t371)
expect(tracker.sync(undefined)).toBeUndefined()
})
})
+51
View File
@@ -0,0 +1,51 @@
/**
* 「用户把焦点切到哪个会话终端 tab」的纯逻辑,与 vscode API 解耦以便单测。
*
* 反向选中工单只关心一件事:活动分组的活动 tab 是不是某个会话终端。
* 由 tab 事件推导这个状态再与上次比较,而不是逐个事件猜测用户意图——
* 这样单 tab 分组重新激活(无 isActive 变化、无 activeTerminal 变化)
* 与 OSC title 改写(同一终端反复上报)都能得到正确结果。
*/
export interface TerminalLike {
readonly name: string
}
export interface TabLike {
readonly label: string
readonly input: unknown
}
/**
* `TabInputTerminal` 没有任何字段,只能用 tab.label 反查 `window.terminals`。
* 终端 name 常被 shell 的 OSC title 序列改写(追加 git branch 等后缀),
* 精确匹配失败后再双向 startsWith 兜底。
*/
export function findTerminalByTabLabel<T extends TerminalLike>(terminals: readonly T[], label: string): T | undefined {
return terminals.find(t => t.name === label)
?? terminals.find(t => label.startsWith(t.name) || t.name.startsWith(label))
}
/** 活动 tab 对应的会话终端;活动 tab 不是终端(如看板 webview)时为 undefined。 */
export function focusedTerminal<T extends TerminalLike>(
activeTab: TabLike | undefined,
isTerminalTab: (input: unknown) => boolean,
terminals: readonly T[],
): T | undefined {
if (!activeTab || !isTerminalTab(activeTab.input))
return undefined
return findTerminalByTabLabel(terminals, activeTab.label)
}
/** 记住上次有焦点的终端,只在焦点真正落到「另一个」终端 tab 上时报告。 */
export class FocusedTerminalTracker<T> {
private current: T | undefined
/** 返回新获得焦点的终端;焦点未变或离开终端 tab 时返回 undefined。 */
sync(next: T | undefined): T | undefined {
if (next === this.current)
return undefined
this.current = next
return next
}
}
+9 -26
View File
@@ -118,7 +118,7 @@ export function findExistingTerminal(panel: KanbanWebviewPanel, expectedName: st
* no-op (user has to press Enter to spawn one).
*/
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
// 如果这次 session/focus 是 onDidChangeActiveTerminal 反选触发的回路
// 如果这次 session/focus 是终端 tab 切换反选触发的回路
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
const REVERSE_LOOP_WINDOW_MS = 200
@@ -135,10 +135,8 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
// side-map is the only way to find it by issueNumber.
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
if (newIssueTerm !== window.activeTerminal) {
panel.programmaticTabReveal = newIssueTerm
if (newIssueTerm !== window.activeTerminal)
newIssueTerm.show(true)
}
return
}
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
@@ -152,12 +150,10 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
for (const name of namePriority) {
const term = findExistingTerminal(panel, name)
if (term) {
// 已经是 active terminal 就不重复 show(避免无谓的回声事件);
// 否则记下程序化 reveal,让随后的 active 变更被吞掉,不回环
if (term !== window.activeTerminal) {
panel.programmaticTabReveal = term
// preserveFocus 只在分组内切 tab、不激活分组,因此不会被当成
// 用户切到终端而反向选中;已是 active terminal 则无需重复 show
if (term !== window.activeTerminal)
term.show(true)
}
return
}
}
@@ -165,27 +161,14 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
}
/**
* 反向选中:用户 column 2 切换 terminal tab 时,从 terminal.name 解析
* issueNumber 并通知 webview 选中对应工单。
* 反向选中:用户把焦点切到 column 2 的某个 terminal tab 时,从 terminal.name
* 解析 issueNumber 并通知 webview 选中对应工单。去重与回声抑制由调用方的
* `FocusedTerminalTracker` 负责,这里每次调用都视为一次真实切换。
*
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
* 终端名按 `issue-${N}-(规划|实施|审查|测试)` 命名;`issue-new-${nonce}-...`
* 是新建工单流程的占位 tab,没有 issue number,跳过。
*/
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
// Dedupe by reference: OSC title rewrites / shell prompt updates fire
// onDidChangeTabs repeatedly for the same terminal. Skip if it's the
// same reference we just handled.
// 吞掉「我们自己 show() 引发的」active 变更回声:handleSessionFocus 在程序化
// show 前把目标终端记到 programmaticTabReveal,这里命中就 return,不反向选中,
// 从根上打断 选中→聚焦→选中 的死循环(基于引用,不依赖脆弱的时间窗)。
// 每次调用都清空,因此只压制紧随其后的那一次回声,用户真实点 tab 不受影响。
const reveal = panel.programmaticTabReveal
panel.programmaticTabReveal = undefined
if (terminal === reveal)
return
if (terminal === panel.lastActiveTerminalRef)
return
panel.lastActiveTerminalRef = terminal
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
if (m) {