@hyzyn/dsh-tty 0.22.3 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -166,6 +166,19 @@
166
166
  const activeTabEl = () => q('.tt_tab[data-active]')
167
167
  const sidOf = (el) => el && el.dataset ? el.dataset.sid : undefined
168
168
  const socket = () => window.__mockSockets[window.__mockSockets.length - 1]
169
+ /*
170
+ * 「面板被最小化了」的判据。
171
+ *
172
+ * ⚠️ 不能看 `.tt_modal`:`data-minimized` 是设在 **`.tt_modalBackdrop`** 上的(CSS 规则
173
+ * `.tt_modalBackdrop[data-minimized]` 也是那一条),`.tt_modal` 只是它的子元素。
174
+ * 早先两个场景(tab-list / tty-assist)都写的 `q('.tt_modal') === null`——最小化**不移除**
175
+ * DOM,于是那两条「Esc 不许把面板最小化」的断言**永远不会触发**:空断言。
176
+ * 这条是被「反证第 E 条一直不红」当场抓出来的,别再退回去。
177
+ */
178
+ const isMinimized = () => {
179
+ const backdrop = q('.tt_modalBackdrop')
180
+ return backdrop === null || backdrop.hasAttribute('data-minimized')
181
+ }
169
182
  const emit = (msg) => {
170
183
  const s = socket()
171
184
  if (s) s._deliver(msg)
@@ -600,6 +613,207 @@
600
613
  return problems.length > 0 ? problems.join(';') : null
601
614
  }
602
615
  },
616
+ /*
617
+ * 设置卡片:AI 辅助小节(0.24.0)。
618
+ *
619
+ * 新小节排在卡片**最下面**,默认视口看不到——这个场景负责把它滚进来,并核对四个控件
620
+ * 都在、且**默认是关**(这个开关会外发终端内容,默认值错了就是隐私事故,不能只靠肉眼)。
621
+ */
622
+ async 'settings-assist'() {
623
+ const host = document.createElement('div')
624
+ host.id = 'preview-settings'
625
+ host.style.cssText = 'position:fixed;inset:24px 24px 24px 260px;overflow:auto;z-index:2000;background:var(--dsw-alias-bg-base);padding:8px;border-radius:16px'
626
+ document.body.appendChild(host)
627
+ const list = document.createElement('ul')
628
+ list.style.cssText = 'display:flex;flex-direction:column;gap:12px;margin:0;padding:0'
629
+ host.appendChild(list)
630
+ const tty = cards.find((c) => c.spec.key === 'tty')
631
+ if (tty === undefined) throw new Error('未注册 tty 设置卡片')
632
+ const root = window.ReactDOM.createRoot(list)
633
+ // 先让模型目录**取不到**(= 宿主没这条路由):卡片必须说「拿不到」,不能笼统说「没有候选」
634
+ window.__PREVIEW_CATALOG = { fail: true }
635
+ root.render(window.React.createElement(tty.Component))
636
+ await waitFor(() => q('.tt_card'), 3000)
637
+ q('.tt_cardHeader').click()
638
+ await waitFor(() => q('.tt_cardBody'), 4000)
639
+ await sleep(400)
640
+ const assistTitle = qa('.tt_cardSection').find((el) => says(el, 'AI 辅助', 'AI assist'))
641
+ if (assistTitle !== undefined) assistTitle.scrollIntoView({ block: 'start' })
642
+ await sleep(300)
643
+ /*
644
+ * D91:**勾上 → 保存 → 必须还是勾着的**。
645
+ *
646
+ * 用户现场就是这条(勾完点保存、勾自己弹回去)。客户端保存成功后拿**响应里的 config**
647
+ * 重置整张表单,所以「到底存下去了没有」的观测点就在这里;夹具的 /config POST 已改成
648
+ * 照真实宿主的样子应用补丁——它要是只原样回一份旧配置,这条断言就永远绿(测不出东西)。
649
+ */
650
+ /*
651
+ * 模型路由是**一个**控件(渠道 + 模型一张候选表)。盯四件事:
652
+ * ① 目录**取不到**时要说「拿不到」(宿主没重启就会这样),不能笼统说「没有候选」——
653
+ * 用户实测就是被这句话引到「我是不是配错了」上去的;
654
+ * ② 取到之后:按渠道分组、点一条**同时**把两个键写好;
655
+ * ③ 手输仍然可用,写法 provider/model(目录只是建议,纯下拉会把人锁死);
656
+ * ④ 这两条路都得真的进保存 payload。
657
+ */
658
+ const setReactInput = (el, value) => {
659
+ // React 在 input 上装了 value 的原生 setter:直接赋值它读到的是旧值,必须先取 prototype 的
660
+ const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
661
+ desc.set.call(el, value)
662
+ el.dispatchEvent(new window.Event('input', { bubbles: true }))
663
+ }
664
+ const routeField = qa('.tt_cardField').find((el) => {
665
+ const label = el.querySelector('.tt_cardLabel')
666
+ return label !== null && (label.textContent.indexOf('模型路由') >= 0 || label.textContent.indexOf('Model route') >= 0)
667
+ })
668
+ const routeInput = routeField === undefined ? null : routeField.querySelector('input.tt_cardInput')
669
+ const routeList = () => (routeField === undefined ? null : routeField.querySelector('.tt_routeList'))
670
+ const routeItems = () => {
671
+ const list = routeList()
672
+ return list === null ? [] : Array.from(list.querySelectorAll('.tt_envItem')).map((el) => el.textContent)
673
+ }
674
+ const routeGroups = () => {
675
+ const list = routeList()
676
+ return list === null ? [] : Array.from(list.querySelectorAll('.tt_envMore')).map((el) => el.textContent)
677
+ }
678
+ const catalogCalls = () => (window.__mockLog || []).filter((line) => line.indexOf('fetch:model-catalog') === 0)
679
+ let unavailableText = ''
680
+ let candidateGroups = []
681
+ let candidateItems = []
682
+ let pickedPair = ''
683
+ let routeReadOnly = null
684
+ let listPosition = ''
685
+ let layoutShift = null
686
+ let defaultLabel = ''
687
+ let defaultCleared = ''
688
+ let labelsRepeatId = false
689
+ if (routeInput !== null) {
690
+ // ① 挂载时目录取不到(夹具预先置了 __PREVIEW_CATALOG.fail)
691
+ routeInput.click()
692
+ await sleep(300)
693
+ unavailableText = routeField.textContent
694
+ // ② 目录恢复:再聚焦一次会重取(失败时 groups 仍为空,守卫放行)
695
+ delete window.__PREVIEW_CATALOG
696
+ const saveBtn = qa('.tt_cardSave').find((el) => saysExact(el, '保存', 'Save'))
697
+ const saveTopBefore = saveBtn === undefined ? null : saveBtn.getBoundingClientRect().top
698
+ routeInput.click()
699
+ await sleep(400)
700
+ candidateGroups = routeGroups()
701
+ candidateItems = routeItems()
702
+ // 下拉选不许顶动卡片(用户实测:「下拉选会破坏布局」)——浮层是 fixed,进不了布局
703
+ const saveTopOpen = saveBtn === undefined ? null : saveBtn.getBoundingClientRect().top
704
+ layoutShift = saveTopBefore === null || saveTopOpen === null ? null : saveTopOpen - saveTopBefore
705
+ listPosition = routeList() === null ? '' : window.getComputedStyle(routeList()).position
706
+ // 名字与 id 并排写两遍太吵(用户实测:「label 有点太长了」)
707
+ labelsRepeatId = candidateItems.some((label) => label.indexOf(' · ') >= 0)
708
+ // ③ 第一行必须是「跟随宿主默认模型」,点它 = 两个键都清空
709
+ const defaultItem = routeList() === null ? null : routeList().querySelector('.tt_envItem')
710
+ defaultLabel = defaultItem === null ? '' : defaultItem.textContent
711
+ if (defaultItem !== null) {
712
+ defaultItem.click()
713
+ await sleep(250)
714
+ }
715
+ defaultCleared = routeInput.value
716
+ // ④ 再点一条模型:渠道 + 模型一起写好
717
+ routeInput.click()
718
+ await sleep(300)
719
+ const modelItem = routeList() === null ? null : routeList().querySelectorAll('.tt_envItem')[1]
720
+ if (modelItem !== null && modelItem !== undefined) {
721
+ modelItem.click()
722
+ await sleep(250)
723
+ }
724
+ pickedPair = routeInput.value
725
+ // ⑤ 只能选,不能打字:控件是只读的(用户定的——目录空的话 DSH 自己的对话也选不出模型)
726
+ routeReadOnly = routeInput.readOnly
727
+ }
728
+
729
+ const toggleBefore = qa('.tt_cardLabel').find((el) => says(el, '失败即解释', 'Explain failures'))
730
+ const boxBefore = toggleBefore === undefined ? null : toggleBefore.parentElement.querySelector('input[type=checkbox]')
731
+ const checkedByDefault = boxBefore === null ? null : boxBefore.checked
732
+ let checkedAfterSave = null
733
+ let postedPayload = null
734
+ let saveBtnLabels = null
735
+ if (boxBefore !== null) {
736
+ boxBefore.click()
737
+ await sleep(120)
738
+ const saveBtns = qa('.tt_cardSave')
739
+ saveBtnLabels = saveBtns.map((el) => el.textContent)
740
+ /*
741
+ * 卡片里不止一个 .tt_cardSave(隧道那一组的「添加隧道」也用这个类),
742
+ * 必须**按文案**挑出真正的保存按钮——按「第一个非空」挑会点到「添加隧道」上,
743
+ * 于是保存根本没发生,而断言只会说「没发出请求」(真机调试时就是这么被骗了一轮)。
744
+ */
745
+ const saveBtn = saveBtns.find((el) => saysExact(el, '保存', 'Save')) ?? saveBtns[saveBtns.length - 1]
746
+ if (saveBtn !== undefined) saveBtn.click()
747
+ await sleep(900)
748
+ const posts = window.__mockConfigPosts || []
749
+ postedPayload = posts.length > 0 ? posts[posts.length - 1] : null
750
+ const afterLabel = qa('.tt_cardLabel').find((el) => says(el, '失败即解释', 'Explain failures'))
751
+ const afterBox = afterLabel === undefined ? null : afterLabel.parentElement.querySelector('input[type=checkbox]')
752
+ checkedAfterSave = afterBox === null ? null : afterBox.checked
753
+ }
754
+ window.__previewAssert = async () => {
755
+ if (assistTitle === undefined) return '设置卡片里没有「AI 辅助」小节'
756
+ if (checkedByDefault !== false) return 'AI 辅助默认必须是**关**(它会外发终端内容),实测默认 checked=' + String(checkedByDefault)
757
+ if (checkedAfterSave === null) return '夹具失效:找不到「失败即解释」复选框'
758
+ if (postedPayload === null) {
759
+ return '夹具失效:保存没有发出 /config 请求(.tt_cardSave 共 ' + String((saveBtnLabels || []).length) + ' 个:' + JSON.stringify(saveBtnLabels) + ')'
760
+ }
761
+ if (postedPayload.assistEnabled !== true) return '保存的 payload 里没有 assistEnabled(D91 客户端那一半)'
762
+ if (checkedAfterSave !== true) return '保存成功后勾又弹回去了(D91 现场)'
763
+ const toggleLabel = qa('.tt_cardLabel').find((el) => says(el, '失败即解释', 'Explain failures'))
764
+ const box = toggleLabel.parentElement.querySelector('input[type=checkbox]')
765
+ if (!box.closest('.tt_cardBody').contains(assistTitle)) return '开关不在设置卡片里(夹具失效)'
766
+ if (routeInput === null) return '设置卡片里没有「模型路由」控件'
767
+ // 这一栏的说明只该有一行短句:控件本身已经把「能选什么」摆在列表里了
768
+ const routeHints = routeField === undefined ? [] : Array.from(routeField.querySelectorAll('.tt_cardHint'))
769
+ if (routeHints.length > 1) {
770
+ return '模型路由一栏堆了 ' + String(routeHints.length) + ' 行说明(其它字段都只有一行):' + JSON.stringify(routeHints.map((el) => el.textContent))
771
+ }
772
+ // 占位符里不许出现具体路由(曾经写死过开发机上的那一对);空态的含义由候选表解释
773
+ if (routeInput.placeholder !== '') {
774
+ return '模型路由控件带了占位符(会把某一对具体路由写给所有人看):' + JSON.stringify(routeInput.placeholder)
775
+ }
776
+ if (unavailableText.indexOf('拿不到候选') < 0 && unavailableText.indexOf('Candidates unavailable') < 0) {
777
+ return '目录取不到时没说「拿不到」(宿主没重启就长这样):' + unavailableText.slice(0, 120)
778
+ }
779
+ if (catalogCalls().length === 0) return '夹具失效:控件没有去取候选目录'
780
+ if (candidateGroups.length < 2) return '候选表没有按渠道分组:' + JSON.stringify(candidateGroups)
781
+ if (candidateItems.length === 0) return '候选表里一个模型都没有'
782
+ if (layoutShift === null) return '夹具失效:量不到保存按钮的位置'
783
+ if (Math.abs(layoutShift) > 0.5) {
784
+ return '打开候选表把卡片布局顶动了 ' + String(Math.round(layoutShift)) + 'px(用户实测现场)'
785
+ }
786
+ if (listPosition !== 'fixed') {
787
+ return '候选表不是浮层(会顶动卡片布局):position=' + listPosition
788
+ }
789
+ if (labelsRepeatId) {
790
+ return '候选行的标签把名字与 id 并排写了两遍(太长):' + JSON.stringify(candidateItems)
791
+ }
792
+ if (defaultLabel.indexOf('跟随宿主默认模型') < 0 && defaultLabel.indexOf('Follow the host default model') < 0) {
793
+ return '候选表第一行不是「跟随宿主默认模型」:' + JSON.stringify(defaultLabel)
794
+ }
795
+ if (defaultCleared !== '') {
796
+ return '点「跟随宿主默认模型」没有把路由清空:' + JSON.stringify(defaultCleared)
797
+ }
798
+ // routeText 只有在**两个键都写好了**时才显示 p/m —— 所以这条同时证明「一次点选写全了对」
799
+ if (pickedPair !== 'mock-provider/mock-fast') {
800
+ return '点候选没有把渠道 + 模型一起写进去:' + JSON.stringify(pickedPair)
801
+ }
802
+ if (routeReadOnly !== true) {
803
+ return '模型路由控件不是只读的(只该能选,不该能打字)'
804
+ }
805
+ if (postedPayload.assistProvider !== 'mock-provider' || postedPayload.assistModel !== 'mock-fast') {
806
+ return '选中的那一对没进保存 payload:' + JSON.stringify([postedPayload.assistProvider, postedPayload.assistModel])
807
+ }
808
+ // 路由是**一个**控件(渠道 + 模型一对):两栏并列的旧形态不许回来
809
+ const labels = qa('.tt_cardLabel').map((el) => el.textContent)
810
+ const routeLabels = labels.filter((text) => text.indexOf('模型路由') >= 0 || text.indexOf('Model route') >= 0)
811
+ if (routeLabels.length !== 1) return '模型路由应当是**一个**控件,实测 ' + String(routeLabels.length) + ' 个:' + JSON.stringify(routeLabels)
812
+ const hint = qa('.tt_cardHint').map((el) => el.textContent).join('\n')
813
+ if (hint.indexOf('默认关') < 0 && hint.indexOf('Off by default') < 0) return 'AI 辅助的说明里没有写明「默认关」'
814
+ return null
815
+ }
816
+ },
603
817
  /* 设置卡片:docker 与 tty 并排(同一张 ul 里),核对两家的观感是否一致 */
604
818
  async 'settings-docker'() {
605
819
  const host = document.createElement('div')
@@ -1184,6 +1398,450 @@
1184
1398
  return null
1185
1399
  }
1186
1400
  },
1401
+
1402
+ /*
1403
+ * D85:只读保留(已退出)的会话不得被算进并发名额。
1404
+ *
1405
+ * 现场(用户截图):面板里**一条标签都没有**(agent 开的会话都跑完/关掉了),点「+」
1406
+ * 却报「会话数已达上限(共 6 个 / 上限 4:本窗口 0 个 + 其他窗口 6 个)——关闭不用的
1407
+ * 窗口/标签」——而面板里根本没有标签可关,终端从此开不出来(只能等宿主重启)。
1408
+ * 那 6 条是 agent 跑完的一次性会话留下的**只读保留态**(D77:进程退出但输出还留着
1409
+ * 可读,宿主 `sessions` 帧里一直带着 `exited: true`,最多 16 条)。
1410
+ *
1411
+ * 夹具:`window.__PREVIEW_EXITED_SESSIONS` = 6 条 exited 会话(宿主 sessions 帧照原样回)。
1412
+ * 断言:点「+」→「本地终端」既不弹上限 toast,也必须**真的开出第二个标签**——
1413
+ * 后者是关键:光断言「没弹 toast」在夹具失效(列表压根没被查过)时也会绿。
1414
+ */
1415
+ async 'limit-retained'() {
1416
+ await openPanel()
1417
+ await waitFor(() => tabs().length === 1)
1418
+ window.__PREVIEW_EXITED_SESSIONS = Array.from({ length: 6 }, (_, i) => ({
1419
+ sid: 'retained-' + String(i + 1),
1420
+ owner: 'agent',
1421
+ kind: 'local',
1422
+ cwd: window.__PREVIEW_CWD,
1423
+ exited: true,
1424
+ exitCode: 0,
1425
+ }))
1426
+ await clickAdd()
1427
+ await clickMenuItem('本地终端', 'Local terminal')
1428
+ await sleep(600) // 上限预检(sessions 往返)→ addTab → spawn → ready
1429
+ window.__previewAssert = async () => {
1430
+ const list = window.__PREVIEW_EXITED_SESSIONS
1431
+ if (!Array.isArray(list) || list.length !== 6) return '夹具失效:没有 6 条只读保留态会话'
1432
+ if (!(window.__mockLog || []).includes('in:sessions')) return '夹具失效:客户端没查过 sessions 帧(走的不是上限预检这条路径)'
1433
+ const toast = q('.tt_toast')
1434
+ if (toast !== null) return '把只读保留的会话算成了并发名额:' + String(toast.textContent)
1435
+ if (tabs().length !== 2) return '新标签没开出来(被上限预检拦下):共 ' + String(tabs().length) + ' 个标签'
1436
+ return null
1437
+ }
1438
+ },
1439
+ /*
1440
+ * 「+」新建按钮的位置(D89)——两半都要钉住:
1441
+ * ① 标签不多时**紧贴最后一个标签**(用户明确不要「钉在头部最右端」那种);
1442
+ * ② 标签栏排满溢出时,它既不能被当成滚动内容滚走、也不能滚出可视区。
1443
+ * 夹具先量 ①(此时只有 1 个标签),再把面板压到 560px(真实宿主里这张卡片常挂在
1444
+ * 侧栏 / 插件页这种窄容器里)× 5 个标签强制溢出,量 ②。
1445
+ */
1446
+ async 'tab-add'() {
1447
+ window.__PREVIEW_CONFIG.maxSessions = 12
1448
+ await openPanel()
1449
+ await sleep(200)
1450
+ // ① 未溢出时的贴附间距(「+」左缘 - 最后一个标签右缘)应当就是容器自己的 gap
1451
+ const hug = (() => {
1452
+ const bar = q('.tt_tabs')
1453
+ const add = q('.tt_tabAdd')
1454
+ const last = qa('.tt_tab').pop()
1455
+ if (bar === null || add === null || last === undefined) return null
1456
+ return {
1457
+ overflow: bar.scrollWidth > bar.clientWidth + 1,
1458
+ gap: Math.round(add.getBoundingClientRect().left - last.getBoundingClientRect().right),
1459
+ edge: bar.dataset.edge ?? '',
1460
+ }
1461
+ })()
1462
+ modal().style.width = '560px'
1463
+ for (let i = 0; i < 4; i += 1) {
1464
+ await clickAdd()
1465
+ await clickMenuItem('本地终端', 'Local terminal')
1466
+ await waitFor(() => tabs().length === i + 2, 4000)
1467
+ }
1468
+ await sleep(250)
1469
+ const strip = q('.tt_tabs')
1470
+ // 先滚到底:用户截图里「+」正是被推到了这一头
1471
+ if (strip !== null) strip.scrollLeft = strip.scrollWidth
1472
+ await sleep(150)
1473
+ window.__previewAssert = async () => {
1474
+ if (hug === null) return '夹具失效:单标签时读不到标签栏 / 「+」/ 标签'
1475
+ if (hug.overflow) return '夹具失效:单标签时标签栏就溢出了,量不到「贴不贴」'
1476
+ if (hug.gap > 8) {
1477
+ return '「+」没有贴着页签:与最后一个标签相距 ' + String(hug.gap)
1478
+ + 'px(应等于容器自己的 gap;钉在头部最右端会长出几十上百 px)'
1479
+ }
1480
+ const bar = q('.tt_tabs')
1481
+ const add = q('.tt_tabAdd')
1482
+ if (bar === null || add === null) return '标签栏或「+」缺失'
1483
+ if (bar.contains(add)) return '「+」是滚动容器 .tt_tabs 的子元素(会被当成滚动内容一起滚走)'
1484
+ if (tabs().length < 5) return '夹具失效:只开出 ' + String(tabs().length) + ' 个标签'
1485
+ if (bar.scrollWidth <= bar.clientWidth + 1) {
1486
+ return '夹具失效:标签栏没有溢出(scrollWidth=' + String(bar.scrollWidth) + ' clientWidth=' + String(bar.clientWidth) + ')'
1487
+ }
1488
+ const head = q('.tt_header').getBoundingClientRect()
1489
+ const sample = (left) => {
1490
+ bar.scrollLeft = left
1491
+ const r = add.getBoundingClientRect()
1492
+ return { left: r.left, right: r.right }
1493
+ }
1494
+ const start = sample(0)
1495
+ const end = sample(bar.scrollWidth)
1496
+ if (Math.abs(end.left - start.left) > 0.5) {
1497
+ return '「+」跟着横向滚动走了:scrollLeft 0→' + String(Math.round(bar.scrollWidth))
1498
+ + ' 时从 x=' + String(Math.round(start.left)) + ' 挪到 x=' + String(Math.round(end.left))
1499
+ }
1500
+ if (end.right > head.right + 0.5 || end.left < head.left - 0.5) {
1501
+ return '「+」落在头部可视区外:x=' + String(Math.round(end.left))
1502
+ + ',头部 ' + String(Math.round(head.left)) + '–' + String(Math.round(head.right))
1503
+ }
1504
+ // ③ 原生滚动条必须已隐藏:不许再占一条高度(0.23.0 用户反馈的那根灰亮条)
1505
+ const chromeH = bar.offsetHeight - bar.clientHeight
1506
+ if (chromeH > 0) return '标签栏仍占着 ' + String(chromeH) + 'px 的原生滚动条高度(应隐藏)'
1507
+ // ④ 两侧渐隐:哪边还有内容哪边 fade;不溢出时**一个遮罩都不该有**
1508
+ if (hug.edge !== '') return '不溢出时不该有 data-edge(会白遮一条),实测 ' + hug.edge
1509
+ const maxLeft = bar.scrollWidth - bar.clientWidth
1510
+ const edgeAt = (left) => {
1511
+ bar.scrollLeft = left
1512
+ bar.dispatchEvent(new Event('scroll'))
1513
+ return bar.dataset.edge ?? ''
1514
+ }
1515
+ const atStart = edgeAt(0)
1516
+ const atMid = edgeAt(Math.round(maxLeft / 2))
1517
+ const atEnd = edgeAt(maxLeft)
1518
+ if (atStart !== 'end' || atMid !== 'both' || atEnd !== 'start') {
1519
+ return '溢出渐隐标记不对:scrollLeft 首/中/尾 → ' + atStart + '/' + atMid + '/' + atEnd + '(应为 end/both/start)'
1520
+ }
1521
+ // ⑤ 滚轮换轴:鼠标用户的纵向滚轮得能推着标签栏走,滚到头则不吞事件
1522
+ bar.scrollLeft = 0
1523
+ const wheelTo = (deltaY) => {
1524
+ const ev = new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true })
1525
+ bar.dispatchEvent(ev)
1526
+ return { left: bar.scrollLeft, prevented: ev.defaultPrevented }
1527
+ }
1528
+ const moved = wheelTo(40)
1529
+ if (moved.left < 39 || moved.left > 41) return '滚轮没有换轴:deltaY=40 只滚到 ' + String(moved.left) + 'px'
1530
+ if (moved.prevented !== true) return '滚轮换轴时没有 preventDefault(页面会跟着一起滚)'
1531
+ bar.scrollLeft = maxLeft
1532
+ const stuck = wheelTo(40)
1533
+ if (stuck.left !== maxLeft || stuck.prevented !== false) {
1534
+ return '滚到尾部后仍在吞滚轮事件:scrollLeft=' + String(stuck.left) + ' prevented=' + String(stuck.prevented)
1535
+ }
1536
+ return null
1537
+ }
1538
+ },
1539
+ /*
1540
+ * 标签栏溢出时的「⋯」标签列表(0.23.0)——用户评审的三条结论都要钉住:
1541
+ * ① 入口只在溢出时出现,且排在「+」**前面**(紧挨标签区右缘,被裁掉的标签就在那一侧);
1542
+ * ② 只列**看不见**的标签(看得见的就在标签栏上,再列一遍是噪音)——判据是「露出的宽度
1543
+ * 不足 TAB_VISIBLE_MIN_PX」,不是「整颗在窗口外」:后者会把只露一线的标签漏掉,
1544
+ * 于是它两边都不出现、凭空消失(**D90**,用户实测「有个隐藏的 tab 终端 3 看不到」);
1545
+ * ③ 点行即切、行内 ✕ 关掉标签后菜单保持打开(连着关几个)、Esc 关闭且不把面板最小化掉。
1546
+ * 夹具:先 1 个标签量「不该出现」,再压到 560px × 8 个标签造出「确实有标签被挤出视野」,
1547
+ * 最后把第 3 个标签精确挤成「只剩 3px」——D90 的现场。
1548
+ */
1549
+ async 'tab-list'() {
1550
+ window.__PREVIEW_CONFIG.maxSessions = 12
1551
+ await openPanel()
1552
+ await sleep(200)
1553
+ const moreAtOneTab = q('.tt_tabMore')
1554
+ // 量**渲染结果**而不是 `hidden` 属性:作者样式里的 display 会盖掉 UA 表那条
1555
+ // display:none(.tt_statsBar[hidden] 踩过同一个坑),只读属性是量不出来的
1556
+ const visibleAtOneTab = moreAtOneTab === null ? null : moreAtOneTab.getBoundingClientRect().width > 0
1557
+ // 「算看得见」的门槛:与 client-src 的 TAB_VISIBLE_MIN_PX 同一契约(两端渐隐带
1558
+ // 22px + 余量)。这里独立算一遍,好验出「实现漏掉了某一类标签」——
1559
+ // D90 就是这么漏的:只露几像素的标签躺在渐隐带里,两边都不认它。
1560
+ const MIN_VISIBLE = 40
1561
+ const visibleWidthOf = (el) => {
1562
+ const v = q('.tt_tabs').getBoundingClientRect()
1563
+ const r = el.getBoundingClientRect()
1564
+ return Math.max(0, Math.min(r.right, v.right) - Math.max(r.left, v.left))
1565
+ }
1566
+ const hiddenSids = () => qa('.tt_tab').filter((el) => visibleWidthOf(el) < MIN_VISIBLE).map((el) => el.dataset.sid)
1567
+ const openMenu = async () => {
1568
+ q('.tt_tabMore').click()
1569
+ await waitFor(() => q('.tt_tabMenu'))
1570
+ await sleep(120)
1571
+ }
1572
+ modal().style.width = '560px'
1573
+ for (let i = 0; i < 7; i += 1) {
1574
+ await clickAdd()
1575
+ await clickMenuItem('本地终端', 'Local terminal')
1576
+ await waitFor(() => tabs().length === i + 2, 4000)
1577
+ }
1578
+ await sleep(250)
1579
+ await openMenu()
1580
+ window.__previewAssert = async () => {
1581
+ const more = q('.tt_tabMore')
1582
+ const add = q('.tt_tabAdd')
1583
+ if (more === null || add === null) return '「⋯」或「+」入口缺失'
1584
+ if (visibleAtOneTab !== false) return '标签栏不溢出时「⋯」不该可见(实测可见=' + String(visibleAtOneTab) + ')'
1585
+ if (more.getBoundingClientRect().width <= 0) return '标签栏已溢出,但「⋯」没有渲染出来'
1586
+ // ① 位置:紧挨标签区的应该是「⋯」,「+」排在它右边(跟工具按钮一排)
1587
+ if (more.getBoundingClientRect().left > add.getBoundingClientRect().left) return '「⋯」应该排在「+」前面'
1588
+ const menu = q('.tt_tabMenu')
1589
+ if (menu === null) return '标签列表没打开'
1590
+ // ② 摆出 D90 的现场:把第 3 个标签只推出「露出 3px」(不是全隐、也不是全显)。
1591
+ // 它躺在左端的渐隐带里,肉眼看不见 —— 这时它必须在列表里。
1592
+ const bar = q('.tt_tabs')
1593
+ const slim = qa('.tt_tab')[2]
1594
+ if (slim === undefined) return '夹具失效:标签不足 3 个'
1595
+ const slimName = slim.querySelector('.tt_tabLabel').textContent
1596
+ bar.scrollLeft += (slim.getBoundingClientRect().right - bar.getBoundingClientRect().left) - 3
1597
+ bar.dispatchEvent(new Event('scroll')) // 让实现按新的滚动位置重算
1598
+ await sleep(80)
1599
+ const slimWidth = Math.round(visibleWidthOf(slim))
1600
+ if (slimWidth > 8) return '夹具失效:没把「' + slimName + '」挤到只剩一线(实测露出 ' + String(slimWidth) + 'px)'
1601
+ if (hiddenSids().indexOf(slim.dataset.sid) < 0) return '夹具失效:连判据都不认它隐藏'
1602
+ if (qa('.tt_tabMenuRow').map((row) => row.dataset.sid).indexOf(slim.dataset.sid) < 0) {
1603
+ return '只露出 ' + String(slimWidth) + 'px 的「' + slimName + '」既看不见、也不在列表里(D90 原样复现)'
1604
+ }
1605
+ // ③ 内容:恰好是「看不见的」那些标签,顺序与标签栏一致
1606
+ const want = hiddenSids()
1607
+ if (want.length === 0) return '夹具失效:没有看不见的标签,本场景验不了「只列隐藏的」'
1608
+ const rows = qa('.tt_tabMenuRow')
1609
+ if (rows.map((row) => row.dataset.sid).join('|') !== want.join('|')) {
1610
+ return '列表内容 ≠ 看不见的标签:列表 [' + rows.map((row) => row.dataset.sid).join(', ')
1611
+ + '],实际隐藏 [' + want.join(', ') + ']'
1612
+ }
1613
+ // ④ 一个都不能丢、也不能多:每个标签要么在栏上真的看得见、要么在列表里
1614
+ for (const el of qa('.tt_tab')) {
1615
+ const sid = el.dataset.sid
1616
+ const name = el.querySelector('.tt_tabLabel').textContent
1617
+ const shown = visibleWidthOf(el) >= MIN_VISIBLE
1618
+ const listed = rows.some((row) => row.dataset.sid === sid)
1619
+ if (!shown && !listed) return '「' + name + '」既看不见也不在列表里(凭空消失)'
1620
+ if (shown && listed) return '「' + name + '」看得见却被列进了列表'
1621
+ }
1622
+ for (let i = 0; i < rows.length; i += 1) {
1623
+ const bar = q('.tt_tab[data-sid="' + rows[i].dataset.sid + '"]')
1624
+ if (bar === null) return '第 ' + String(i + 1) + ' 行指向一个不存在的标签'
1625
+ const rowState = rows[i].querySelector('.tt_tabDot').dataset.state
1626
+ const barState = bar.querySelector('.tt_tabDot').dataset.state
1627
+ if (rowState !== barState) return '第 ' + String(i + 1) + ' 行状态点与标签栏不一致:' + rowState + ' vs ' + barState
1628
+ }
1629
+ // 当前标签**隐藏时**才高亮;它看得见时列表里不该有任何高亮行
1630
+ const activeBar = q('.tt_tab[data-active]')
1631
+ if (activeBar === null) return '夹具失效:标签栏里没有活动标签'
1632
+ const activeRow = menu.querySelector('.tt_tabMenuItem[data-active]')
1633
+ const activeHidden = want.indexOf(activeBar.dataset.sid) >= 0
1634
+ if (activeHidden && activeRow === null) return '当前标签是隐藏的,列表里却没高亮'
1635
+ if (!activeHidden && activeRow !== null) return '当前标签看得见,列表里不该有高亮行'
1636
+ // 行内 ✕:关掉一个标签,菜单原地保留、内容按新的可见情况重算
1637
+ const before = tabs().length
1638
+ rows[0].querySelector('.tt_addMenuEdit').click()
1639
+ await sleep(250)
1640
+ if (q('.tt_tabMenu') === null) return '行内 ✕ 关掉标签后菜单也关了(应保持打开,方便连着关几个)'
1641
+ if (tabs().length !== before - 1) return '行内 ✕ 没关掉标签:' + String(before) + ' → ' + String(tabs().length)
1642
+ if (qa('.tt_tabMenuRow').map((row) => row.dataset.sid).join('|') !== hiddenSids().join('|')) {
1643
+ return '关完没有按新的可见情况重渲染列表'
1644
+ }
1645
+ // 点一行 → 切过去(标签栏会把它滚进视野)+ 菜单关闭
1646
+ const firstRow = qa('.tt_tabMenuRow')[0]
1647
+ if (firstRow === undefined) return '夹具失效:列表空了'
1648
+ const wantSid = firstRow.dataset.sid
1649
+ firstRow.querySelector('.tt_tabMenuItem').click()
1650
+ await sleep(250)
1651
+ if (q('.tt_tabMenu') !== null) return '选了一个标签后菜单没关'
1652
+ const afterActive = q('.tt_tab[data-active]')
1653
+ if (afterActive === null) return '切换之后标签栏没有活动标签'
1654
+ if (afterActive.dataset.sid !== wantSid) {
1655
+ return '没切到选中的标签:当前 ' + afterActive.dataset.sid + ',选的是 ' + wantSid
1656
+ }
1657
+ // Esc 关闭,且不许把面板最小化掉(浮层要先吃掉这个 Esc)
1658
+ await openMenu()
1659
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1660
+ await sleep(200)
1661
+ if (q('.tt_tabMenu') !== null) return 'Esc 没关掉标签列表'
1662
+ if (isMinimized()) return 'Esc 把面板最小化了(浮层该先把它吃掉)'
1663
+ await openMenu() // 截图留一个「列表开着」的样子
1664
+ return null
1665
+ }
1666
+ },
1667
+
1668
+ /*
1669
+ * AI 辅助「失败即解释」(0.24.0)——「零输入入口」这条链路的完整走查。
1670
+ *
1671
+ * 钉五件事,其中三条带**反向对照**:
1672
+ * ① 开关**默认关**时,同样一帧 hint 什么都不点亮(默认关 = 界面上根本没这个功能);
1673
+ * ② 打开后徽标才出现,且**点它才发请求**——那一步会把终端内容送出本机,不许自动发;
1674
+ * ③ 答案原样显示:模型输出是**不可信文本**,`<b>` 必须当字面量(走 textContent);
1675
+ * ④ 「填入」写进 PTY 的字节 = Ctrl-U + 命令,**结尾不带 \r/\n**(填进去 ≠ 跑起来);
1676
+ * ⑤ Esc 只收浮层,不许把整个面板最小化。
1677
+ */
1678
+ async 'tty-assist'() {
1679
+ await openPanel()
1680
+ await waitFor(() => tabs().length === 1)
1681
+ const sid = sidOf(activeTabEl())
1682
+ const badge = () => q('.tt_assistBadge')
1683
+ const badgeShown = () => {
1684
+ const el = badge()
1685
+ return el !== null && el.hidden !== true && el.getBoundingClientRect().width > 0
1686
+ }
1687
+ const asked = () => (window.__mockLog || []).filter((line) => line === 'fetch:assist').length
1688
+ const fail = (why) => why
1689
+
1690
+ // ① 默认关:同样一帧 hint 什么都不该点亮
1691
+ emit({ t: 'hint', sid, kind: 'failure', exitCode: 2, at: Date.now() })
1692
+ await sleep(250)
1693
+ const shownWhileOff = badgeShown()
1694
+ // 打开开关走**真实路径**(点「+」会顺手刷 /api/dsh-tty/config),不是直接塞缓存
1695
+ window.__PREVIEW_CONFIG.assistEnabled = true
1696
+ await clickAdd()
1697
+ q('.tt_tabAdd').click()
1698
+ await sleep(250)
1699
+
1700
+ // ② 徽标出现,且此刻**还没有**任何 assist 请求
1701
+ emit({ t: 'hint', sid, kind: 'failure', exitCode: 2, at: Date.now() })
1702
+ await sleep(250)
1703
+ const shownWhileOn = badgeShown()
1704
+ const askedBeforeClick = asked()
1705
+ const badgeText = badge() === null ? '' : badge().textContent
1706
+
1707
+ if (badge() !== null) badge().click()
1708
+ await waitFor(() => q('.tt_assistMenu') !== null, 3000)
1709
+ await sleep(300)
1710
+ const answerEl = q('.tt_assistAnswer')
1711
+ const answerText = answerEl === null ? '' : answerEl.textContent
1712
+ const answerHasMarkup = answerEl !== null && answerEl.querySelector('b') !== null
1713
+ const cmdText = q('.tt_assistCmd') === null ? '' : q('.tt_assistCmd').textContent
1714
+ /*
1715
+ * 美化这一轮(2026-10-01):徽标与命令块都拆成了「外层容器 + 内层节点」。徽标多了一枚
1716
+ * 状态圆点,而 syncAssistBadge 原来是直接写按钮的 textContent —— 那样会把圆点整个抹掉,
1717
+ * 所以这里按**节点存在性**盯着,而不是只看文字对不对。
1718
+ */
1719
+ const badgeDot = badge() === null ? null : badge().querySelector('.tt_assistDot')
1720
+ const badgeTextEl = badge() === null ? null : badge().querySelector('.tt_assistBadgeText')
1721
+ const exitChip = q('.tt_assistCode')
1722
+ const cmdLabel = q('.tt_assistCmdLabel')
1723
+
1724
+ // ④ 「填入」的字节(先清帧,只留这一次点击的)
1725
+ window.__mockFrames.length = 0
1726
+ const fillBtn = q('.tt_assistFill')
1727
+ if (fillBtn !== null) fillBtn.click()
1728
+ await sleep(200)
1729
+ const inputs = (window.__mockFrames || []).filter((frame) => frame.t === 'input')
1730
+
1731
+ // ⑤ 再来一轮:Esc 只收浮层
1732
+ emit({ t: 'hint', sid, kind: 'failure', exitCode: 1, at: Date.now() })
1733
+ await sleep(200)
1734
+ if (badge() !== null) badge().click()
1735
+ await waitFor(() => q('.tt_assistMenu') !== null, 3000)
1736
+ await sleep(250)
1737
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1738
+ await sleep(250)
1739
+ const menuAfterEsc = q('.tt_assistMenu')
1740
+ const minimized = isMinimized()
1741
+
1742
+ // 宿主说「没有可用的模型路由」时:浮层里要有一句能照做的原因,不许静默什么都不发生
1743
+ window.__PREVIEW_ASSIST = { error: '没有可用的模型路由:在卡片里填 provider / model,或先给宿主配一个默认模型' }
1744
+ emit({ t: 'hint', sid, kind: 'failure', exitCode: 2, at: Date.now() })
1745
+ await sleep(200)
1746
+ if (badge() !== null) badge().click()
1747
+ await waitFor(() => q('.tt_assistMenu') !== null, 3000)
1748
+ await sleep(300)
1749
+ const errorText = q('.tt_assistError') === null ? '' : q('.tt_assistError').textContent
1750
+ // 失败态也要有出路(tty D92):底部那排按钮(复制报错 / 关闭)——只有一行红字、没有按钮的浮层是死路
1751
+ const errorFoot = q('.tt_assistFoot')
1752
+ const errorButtons = errorFoot === null ? 0 : errorFoot.querySelectorAll('button').length
1753
+ const errorCloses = errorFoot === null ? null : errorFoot.querySelector('.tt_assistClose')
1754
+ /*
1755
+ * **长答案**:浮层会在内容到达后长高。只按「转圈时的高度」定位一次的话,底部那排
1756
+ * 按钮会被推出视口(用户看得到答案、点不到「填入」)——真机截图暴露过。
1757
+ * 短答案区分不出来(两种做法都放得下),所以这一段刻意用长正文把差异逼出来。
1758
+ */
1759
+ window.__PREVIEW_ASSIST = {
1760
+ answer: '发生了什么:' + '这是一段很长的说明,用来把浮层撑高。'.repeat(5)
1761
+ + '\n\n下一步:\n' + Array.from({ length: 14 }, (_v, i) => '第 ' + String(i + 1) + ' 行:继续补充上下文,直到正文超过自己的高度上限。').join('\n'),
1762
+ command: 'npm install',
1763
+ }
1764
+ emit({ t: 'hint', sid, kind: 'failure', exitCode: 2, at: Date.now() })
1765
+ await sleep(200)
1766
+ if (badge() !== null) badge().click()
1767
+ await waitFor(() => q('.tt_assistAnswer') !== null, 3000)
1768
+ await sleep(300)
1769
+ const longMenu = q('.tt_assistMenu')
1770
+ const longBox = longMenu === null ? null : longMenu.getBoundingClientRect()
1771
+ const longBody = q('.tt_assistBody')
1772
+ const longScrolled = longBody !== null && longBody.scrollHeight > longBody.clientHeight + 1
1773
+ // 恢复默认答案,让截图里留一个「有答案」的样子
1774
+ delete window.__PREVIEW_ASSIST
1775
+ emit({ t: 'hint', sid, kind: 'failure', exitCode: 2, at: Date.now() })
1776
+ await sleep(200)
1777
+ if (badge() !== null) badge().click()
1778
+ await waitFor(() => q('.tt_assistAnswer') !== null, 3000)
1779
+ await sleep(200)
1780
+
1781
+ /*
1782
+ * ⑥ 同一条失败**只问一次**(tty D94):Esc 收掉浮层后再点徽标,应该直接显示缓存答案,
1783
+ * 而不是又发一次请求(POST 早已送达宿主,abort 只停客户端这头,宿主照跑模型)。
1784
+ */
1785
+ const askedBeforeReopen = asked()
1786
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1787
+ await sleep(150)
1788
+ if (badge() !== null) badge().click()
1789
+ await waitFor(() => q('.tt_assistAnswer') !== null, 3000)
1790
+ await sleep(200)
1791
+ const askedAfterReopen = asked()
1792
+ const reopenedAnswer = q('.tt_assistAnswer') === null ? '' : q('.tt_assistAnswer').textContent
1793
+
1794
+ window.__previewAssert = async () => {
1795
+ if (sid === undefined || sid === null) return fail('夹具失效:读不到活动标签的 sid')
1796
+ if (shownWhileOff) return fail('开关默认关着,hint 帧却点亮了徽标(默认关 = 界面上不该有它)')
1797
+ if (!shownWhileOn) return fail('开关打开后徽标没出现(功能等于不存在)')
1798
+ if (badgeText.indexOf('2') < 0) return fail('徽标没写出退出码:' + badgeText)
1799
+ if (askedBeforeClick !== 0) return fail('点徽标之前就发了 ' + String(askedBeforeClick) + ' 次 assist 请求(终端内容被自动外发)')
1800
+ if (asked() === 0) return fail('点了徽标却没发请求')
1801
+ if (answerHasMarkup) return fail('答案里的 <b> 被当成 HTML 解析了(模型输出是不可信文本)')
1802
+ // 宿主给什么就显示什么(一个字都不许被客户端顺手改掉/解释掉)
1803
+ const expectedAnswer = '发生了什么:<b>这不是加粗</b>,是输出里的原文;依赖装漏了一个。\n\n下一步:\n装完再跑一次。'
1804
+ if (answerText !== expectedAnswer) {
1805
+ return fail('正文与宿主给的文本不一致:' + JSON.stringify(answerText))
1806
+ }
1807
+ // 命令块里**只许有命令**(小标题在外层):混进标题,「填入」就会把标题一起敲进终端
1808
+ if (cmdText !== 'npm install') return fail('命令块里不止有命令:' + JSON.stringify(cmdText))
1809
+ if (cmdLabel === null || cmdLabel.textContent === '') return fail('建议命令块没有小标题')
1810
+ if (badgeDot === null) return fail('徽标里没有状态圆点(多半是被 syncAssistBadge 的 textContent 抹掉了)')
1811
+ if (badgeTextEl === null) return fail('徽标文案没落在内层 span 上')
1812
+ if (exitChip === null || exitChip.textContent.indexOf('2') < 0) {
1813
+ return fail('退出码药丸没渲染出来:' + (exitChip === null ? 'null' : exitChip.textContent))
1814
+ }
1815
+ if (inputs.length !== 1) return fail('「填入」发出的 input 帧数不对:' + String(inputs.length))
1816
+ if (inputs[0].d !== '\u0015npm install') return fail('「填入」的字节不对:' + JSON.stringify(inputs[0].d))
1817
+ if (menuAfterEsc !== null) return fail('Esc 没关掉答案浮层')
1818
+ if (minimized) return fail('Esc 把整个面板最小化了(浮层该先把它吃掉)')
1819
+ if (errorText.indexOf('没有可用的模型路由') < 0) return fail('宿主报错时浮层没说明原因:' + errorText.slice(0, 80))
1820
+ if (errorCloses === null || errorButtons !== 2) {
1821
+ return fail('失败态浮层没有可用的按钮(只剩 Esc / 点外面):按钮数=' + String(errorButtons))
1822
+ }
1823
+ const shot = q('.tt_assistMenu')
1824
+ if (shot === null) return fail('截图态没有浮层')
1825
+ const box = shot.getBoundingClientRect()
1826
+ if (box.bottom > window.innerHeight - 4 || box.top < 4) {
1827
+ return fail('答案浮层超出视口(底部按钮点不到):top=' + String(Math.round(box.top))
1828
+ + ' bottom=' + String(Math.round(box.bottom)) + ' 视口高=' + String(window.innerHeight))
1829
+ }
1830
+ if (longScrolled !== true) return fail('夹具失效:长答案没有被正文高度上限约束(区分不出定位问题)')
1831
+ if (longBox === null) return fail('长答案那一轮没有浮层')
1832
+ if (longBox.bottom > window.innerHeight - 4 || longBox.top < 4) {
1833
+ return fail('长答案把浮层顶出视口(底部按钮点不到):top=' + String(Math.round(longBox.top))
1834
+ + ' bottom=' + String(Math.round(longBox.bottom)) + ' 视口高=' + String(window.innerHeight))
1835
+ }
1836
+ if (askedAfterReopen !== askedBeforeReopen) {
1837
+ return fail('重开浮层又发了 ' + String(askedAfterReopen - askedBeforeReopen) + ' 次请求(同一条失败只该问一次)')
1838
+ }
1839
+ if (reopenedAnswer.indexOf('发生了什么') < 0) return fail('重开浮层没显示缓存答案:' + reopenedAnswer.slice(0, 60))
1840
+ if (q('.tt_assistFill') === null) return fail('浮层里没有「填入」按钮')
1841
+ if (q('.tt_assistAnswer') === null) return fail('截图态没停在「有答案」上(夹具失效)')
1842
+ return null
1843
+ }
1844
+ },
1187
1845
  }
1188
1846
 
1189
1847
  const name = new URLSearchParams(location.search).get('scenario') || 'local'