m-drawer.ts 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244
  1. import { NgTemplateOutlet } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, output, signal, untracked } from '@angular/core';
  3. import { NavigationEnd, Router } from '@angular/router';
  4. import { filter } from 'rxjs';
  5. import { Agent } from '../../../core/api/models';
  6. import { groupOf } from '../../../core/chat-session';
  7. import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
  8. import { ChatSessionService } from '../../../core/chat-session.service';
  9. import { LiveService } from '../../../core/live.service';
  10. import { MobileChatViewService, SessionRow } from '../../../core/mobile-chat-view.service';
  11. import { WorkspaceService } from '../../../core/workspace.service';
  12. import { NavMenuService } from '../../../core/nav-menu.service';
  13. import { MNavCustomComponent } from '../m-nav-custom/m-nav-custom';
  14. import { UiMDialog } from '../../../ui/mobile/components/dialog/dialog';
  15. import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
  16. import { LockScrollDirective } from '../../directives/lock-scroll.directive';
  17. /** 抽屉布局常量(与 PC 左栏 `SessionTreeComponent.PRIMARY_COUNT` 对齐) */
  18. class MNavConstants {
  19. static readonly PRIMARY_COUNT = 4;
  20. }
  21. /** 底部操作菜单的一项 */
  22. interface MenuItem {
  23. label: string;
  24. run: () => void;
  25. danger?: boolean;
  26. }
  27. import { isMatrixReady } from '../../../core/format';
  28. import { MAvatarComponent } from '../m-avatar/m-avatar';
  29. import { MBadgeComponent } from '../m-badge/m-badge';
  30. /** 抽屉一级菜单的一行 */
  31. interface NavRow {
  32. key: string;
  33. path: string;
  34. label: string;
  35. icon: string;
  36. active: boolean;
  37. }
  38. /**
  39. * 左侧滑出抽屉(Demo 04)
  40. *
  41. * 三段结构(与 Demo 一致):
  42. * ① 顶部「新建对话」主入口(选择数字生命)
  43. * ② 一级菜单(会话 / 项目 / 任务 / 事件 / 成果 / 工作台 / 通讯录 / 我的)
  44. * ③ 会话树:工作区 / 日常对话 / 已归档 —— **原地折叠,不重建 DOM**
  45. *
  46. * 路由与菜单项仍走项目既有页面(`/mobile/*`),不自建一套导航。
  47. */
  48. @Component({
  49. selector: 'app-m-drawer',
  50. changeDetection: ChangeDetectionStrategy.OnPush,
  51. imports: [NgTemplateOutlet, MAvatarComponent, MBadgeComponent, MNavCustomComponent, LockScrollDirective, UiMDialog],
  52. template: `
  53. <div class="m-mask" [class.show]="open()" (click)="close.emit()"></div>
  54. <aside class="m-drawer" [class.show]="open()" role="dialog" aria-label="导航" aria-modal="true" [appLockScroll]="open()">
  55. <div class="m-drawer-head">
  56. <button type="button" class="m-entry m-entry--primary" (click)="newChat.emit()">
  57. <span class="m-entry-lead"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i></span>
  58. <b>新建对话</b>
  59. <span class="m-entry-kbd">选择数字生命</span>
  60. </button>
  61. @if (quickNewAgentId()) {
  62. <button
  63. type="button"
  64. class="m-entry m-entry--quick"
  65. (click)="quickNew.emit(quickNewAgentId()!)"
  66. >
  67. <span class="m-entry-lead"><i class="fa-solid fa-plus" aria-hidden="true"></i></span>
  68. <b>在本容器新建</b>
  69. <span class="m-entry-kbd">不用再挑一次</span>
  70. </button>
  71. }
  72. </div>
  73. <nav class="m-drawer-nav" aria-label="功能">
  74. <!--
  75. 菜单项来自 NavMenuService(2026-11-03):顺序与显隐由用户配置,
  76. 与 PC 左栏共用同一份偏好。
  77. 折叠规则与 PC 一致:前 4 项直显,其余收进「更多」——
  78. 抽屉纵向空间有限,全平铺会把下面的会话区挤没。
  79. -->
  80. @for (item of primaryNav(); track item.key) {
  81. <button
  82. type="button"
  83. class="m-entry"
  84. [class.on]="item.active"
  85. (click)="go(item.path)"
  86. >
  87. <span class="m-entry-lead"><i [class]="item.icon" aria-hidden="true"></i></span>
  88. <b>{{ item.label }}</b>
  89. <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
  90. </button>
  91. }
  92. @if (moreNav().length > 0) {
  93. <button
  94. type="button"
  95. class="m-entry"
  96. [class.on]="moreOpen()"
  97. [attr.aria-expanded]="moreOpen()"
  98. (click)="moreOpen.set(!moreOpen())"
  99. >
  100. <span class="m-entry-lead"><i class="fa-solid fa-ellipsis" aria-hidden="true"></i></span>
  101. <b>更多</b>
  102. <span class="m-entry-kbd">{{ moreNav().length }}</span>
  103. <i
  104. class="fa-solid m-chev"
  105. [class.fa-chevron-down]="moreOpen()"
  106. [class.fa-chevron-right]="!moreOpen()"
  107. aria-hidden="true"
  108. ></i>
  109. </button>
  110. <!-- 展开态原地展开,不弹浮层(移动端没有悬浮定位的余量) -->
  111. @if (moreOpen()) {
  112. <div class="m-more">
  113. @for (item of moreNav(); track item.key) {
  114. <button
  115. type="button"
  116. class="m-entry m-entry--sub"
  117. [class.on]="item.active"
  118. (click)="go(item.path)"
  119. >
  120. <span class="m-entry-lead"><i [class]="item.icon" aria-hidden="true"></i></span>
  121. <b>{{ item.label }}</b>
  122. <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
  123. </button>
  124. }
  125. <!--
  126. 企业管理入口已移除(2026-11-11 用户裁决)。
  127. 落地页 /mobile/admin 下线,成员管理改由菜单目录
  128. (NAV_CATALOG 的 admin-users)直接指向 /admin/users,
  129. 不再经过中间页。管理员仍可在「更多」里看到「成员管理」。
  130. -->
  131. <!-- 编辑菜单(2026-11-03 用户要求:收进「更多」,不占一级位置) -->
  132. <button type="button" class="m-entry m-entry--sub" (click)="customOpen.set(true)">
  133. <span class="m-entry-lead"><i class="fa-solid fa-sliders" aria-hidden="true"></i></span>
  134. <b>编辑菜单</b>
  135. <span class="m-entry-kbd">排序 / 显隐</span>
  136. </button>
  137. </div>
  138. }
  139. }
  140. </nav>
  141. <!-- 滚动区:从「对话」标题起,到抽屉底部(唯一可滚区域) -->
  142. <nav class="m-drawer-body" aria-label="对话">
  143. <div class="m-drawer-caption">对话</div>
  144. <!-- ① 工作区:完整功能(加载 / 新建 / 重命名 / 删除 / 空工作区也显示) -->
  145. <div class="m-ws-bar">
  146. <span>工作区</span>
  147. <!-- 新建「工作区」:与行内的「新建对话」区分,用图标 + 无障碍标签 -->
  148. <button
  149. type="button"
  150. class="m-ws-create"
  151. aria-label="新建工作区"
  152. title="新建工作区"
  153. (click)="newWorkspace()"
  154. >
  155. <i class="fa-solid fa-plus" aria-hidden="true"></i>
  156. </button>
  157. </div>
  158. @for (ws of workspaces(); track ws.id) {
  159. <section>
  160. <div class="m-ws-row">
  161. <button
  162. type="button"
  163. class="m-tree-toggle"
  164. [attr.aria-expanded]="isOpen(ws.id)"
  165. [attr.aria-controls]="'tree-' + ws.id"
  166. (click)="toggle(ws.id)"
  167. >
  168. <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
  169. {{ ws.name }}<span class="m-tree-count">{{ rowsInWorkspace(ws.id).length }}</span>
  170. </button>
  171. <!-- 在工作区中新建对话(2026-11-04 用户要求) -->
  172. <button
  173. type="button"
  174. class="m-ws-add"
  175. [attr.aria-label]="'在 ' + ws.name + ' 中新建对话'"
  176. (click)="newChatIn(ws.id)"
  177. >
  178. <i class="fa-solid fa-plus" aria-hidden="true"></i>
  179. </button>
  180. <button
  181. type="button"
  182. class="m-ws-more"
  183. [attr.aria-label]="ws.name + ' 的操作'"
  184. (click)="openWsMenu(ws.id)"
  185. >
  186. <i class="fa-solid fa-ellipsis" aria-hidden="true"></i>
  187. </button>
  188. </div>
  189. <div [id]="'tree-' + ws.id" [hidden]="!isOpen(ws.id)">
  190. <ng-container *ngTemplateOutlet="tree; context: { $implicit: rowsInWorkspace(ws.id) }" />
  191. <!-- 归档回到原分组(2026-11-08):该工作区的已归档会话留在这里 -->
  192. <ng-container
  193. *ngTemplateOutlet="archBlock; context: { $implicit: archivedIn(ws.id), key: 'arch-' + ws.id }"
  194. />
  195. </div>
  196. </section>
  197. } @empty {
  198. <p class="m-tree-empty">还没有工作区,点「新建」按项目分开存放对话</p>
  199. }
  200. <!--
  201. 🔒 2026-11-11 用户裁决:群聊入口整体隐藏(PC + 移动端一致)。
  202. 恢复:把下面的 false && 去掉,并同步恢复 session-tree.ts 的对应分组。
  203. -->
  204. @if (false && groupSessions().length > 0) {
  205. <section>
  206. <button
  207. type="button"
  208. class="m-tree-toggle"
  209. [attr.aria-expanded]="isOpen('__group')"
  210. aria-controls="tree-group"
  211. (click)="toggle('__group')"
  212. >
  213. <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
  214. 群聊<span class="m-tree-count">{{ groupSessions().length }}</span>
  215. </button>
  216. <div id="tree-group" [hidden]="!isOpen('__group')">
  217. <ng-container *ngTemplateOutlet="tree; context: { $implicit: groupSessions() }" />
  218. <!-- 归档回到原分组(2026-11-08):群聊的已归档会话留在群聊下面 -->
  219. <ng-container
  220. *ngTemplateOutlet="archBlock; context: { $implicit: archivedGroup(), key: 'arch-group' }"
  221. />
  222. </div>
  223. </section>
  224. }
  225. <!-- ② 日常对话(无工作区归属) -->
  226. <section>
  227. <button
  228. type="button"
  229. class="m-tree-toggle"
  230. [attr.aria-expanded]="isOpen('__daily')"
  231. aria-controls="tree-daily"
  232. (click)="toggle('__daily')"
  233. >
  234. <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
  235. 日常对话<span class="m-tree-count">{{ daily().length }}</span>
  236. </button>
  237. <div id="tree-daily" [hidden]="!isOpen('__daily')">
  238. <ng-container *ngTemplateOutlet="tree; context: { $implicit: daily() }" />
  239. <!-- 归档回到原分组(2026-11-08):日常的已归档会话留在日常下面 -->
  240. <ng-container
  241. *ngTemplateOutlet="archBlock; context: { $implicit: archivedDaily(), key: 'arch-daily' }"
  242. />
  243. </div>
  244. </section>
  245. </nav>
  246. </aside>
  247. <!-- 编辑菜单(排序 + 显隐),与 PC 共用同一份偏好 -->
  248. <app-m-nav-custom [(open)]="customOpen" />
  249. <!--
  250. 二次确认(2026-11-04,对齐 PC):归档 / 删除 / 删工作区 一律走居中 dialog。
  251. 规范 18 §三-13:删除类操作必须二次确认,不用 popconfirm。
  252. -->
  253. <ui-m-dialog
  254. [(open)]="confirmOpen"
  255. [title]="confirmTitle()"
  256. [message]="confirmMessage()"
  257. [confirmText]="confirmText()"
  258. (confirm)="runConfirm()"
  259. />
  260. <!-- 底部操作菜单(工作区 / 会话共用) -->
  261. @if (menu(); as m) {
  262. <div class="m-act-root" role="dialog" aria-modal="true" [attr.aria-label]="m.title" [appLockScroll]="true">
  263. <div class="m-act-mask" (click)="menu.set(null)"></div>
  264. <section class="m-act">
  265. <div class="m-grabber" aria-hidden="true"></div>
  266. <p class="m-act-title">{{ m.title }}</p>
  267. @for (item of m.items; track item.label) {
  268. <button
  269. type="button"
  270. class="m-act-item"
  271. [class.danger]="item.danger"
  272. (click)="runMenu(item)"
  273. >
  274. {{ item.label }}
  275. </button>
  276. }
  277. </section>
  278. <button type="button" class="m-act-cancel" (click)="menu.set(null)">取消</button>
  279. </div>
  280. }
  281. <ng-template #tree let-rows>
  282. @for (row of rows; track row.session.id) {
  283. <div class="m-tree-line">
  284. <button type="button" class="m-tree-item" [class.on]="row.session.id === activeSessionId()" (click)="goChat(row)">
  285. <app-m-avatar [name]="row.agent?.name ?? '?'" size="sm" />
  286. <span class="m-tree-text">
  287. <span class="m-tree-title">
  288. @if (row.session.kind === 'group') {
  289. <i class="fa-solid fa-users m-tree-group-icon" aria-label="群聊"></i>
  290. }
  291. {{ row.session.title }}
  292. </span>
  293. <!-- 容器名:明确「在跟哪个数字生命对话」(2026-11-04) -->
  294. <span class="m-tree-agent">
  295. @if (row.session.kind === 'group') {
  296. {{ row.session.memberIds?.length ?? 0 }} 个容器
  297. } @else {
  298. <!-- 容器状态点(2026-11-08):与 PC 左栏、会话列表同一口径 -->
  299. <i
  300. class="m-state-dot"
  301. [attr.data-tone]="stateOf(row).tone"
  302. [attr.aria-label]="stateOf(row).text"
  303. ></i>
  304. {{ row.agent?.name ?? '未知容器' }}
  305. }
  306. </span>
  307. </span>
  308. @if (row.unread > 0) {
  309. <app-m-badge tone="danger">{{ row.unread }}</app-m-badge>
  310. }
  311. </button>
  312. <!-- 移动到工作区 / 归档 / 删除(长按与「⋯」等价) -->
  313. <button
  314. type="button"
  315. class="m-tree-more"
  316. aria-label="会话操作"
  317. (click)="openSessionMenu(row)"
  318. >
  319. <i class="fa-solid fa-ellipsis" aria-hidden="true"></i>
  320. </button>
  321. </div>
  322. } @empty {
  323. <p class="m-tree-empty">暂无会话</p>
  324. }
  325. </ng-template>
  326. <!--
  327. 「已归档」子块(2026-11-08):挂在**原分组**下面,默认收起。
  328. 归档是「收起来看」,不是「搬到别的分组」——群聊归档仍在群聊下,
  329. 工作区归档仍在那个工作区下,日常归档仍在日常下。
  330. -->
  331. <ng-template #archBlock let-rows let-key="key">
  332. @if (rows.length > 0) {
  333. <button
  334. type="button"
  335. class="m-arch-toggle"
  336. [attr.aria-expanded]="isOpen(key)"
  337. (click)="toggle(key)"
  338. >
  339. <i
  340. class="fa-solid m-arrow"
  341. [class.fa-chevron-right]="!isOpen(key)"
  342. [class.fa-chevron-down]="isOpen(key)"
  343. aria-hidden="true"
  344. ></i>
  345. 已归档<span class="m-tree-count">{{ rows.length }}</span>
  346. </button>
  347. @if (isOpen(key)) {
  348. <ng-container *ngTemplateOutlet="tree; context: { $implicit: rows }" />
  349. }
  350. }
  351. </ng-template>
  352. `,
  353. styles: [
  354. `
  355. /*
  356. * 【2026-11-08】不能用 display: contents。
  357. *
  358. * 它会让抽屉子树的层叠提升到父级层叠上下文,与底栏(fixed,出现在 shell
  359. * 模板更后面)直接比较 —— 同为 fixed 时后者胜出,抽屉内的操作菜单被底栏压住。
  360. *
  361. * 改成 display:block:Host 有真实盒子,相对定位/高度约束才生效;
  362. * 抽屉与遮罩都是 position:fixed 且自带 z-index,层叠顺序不受影响。
  363. */
  364. :host {
  365. display: block;
  366. }
  367. .m-mask {
  368. position: fixed;
  369. inset: 0;
  370. z-index: calc(var(--z-modal) - 1);
  371. background: var(--overlay);
  372. opacity: 0;
  373. pointer-events: none;
  374. transition: opacity var(--motion-panel) var(--ease-standard);
  375. }
  376. .m-mask.show {
  377. opacity: 1;
  378. pointer-events: auto;
  379. }
  380. .m-drawer {
  381. position: fixed;
  382. top: 0;
  383. bottom: 0;
  384. left: 0;
  385. z-index: var(--z-modal);
  386. width: min(84%, 330px);
  387. box-sizing: border-box;
  388. display: flex;
  389. flex-direction: column;
  390. /*
  391. * 抽屉最外层**不滚动**(用户 2026-11-08 明确指出):唯一滚动区是下方的
  392. * .m-drawer-body。这里只负责撑满视口 + 裁掉溢出。
  393. */
  394. overflow: hidden;
  395. /*
  396. * 底部只预留安全区:底栏在抽屉打开时被 body:has(.m-drawer.show) .m-tabbar
  397. * 直接隐藏(styles.scss),不需要再为它留 70px —— 那 70px 就是用户看到的
  398. * 「下面一块空白没利用起来」。
  399. */
  400. padding: calc(var(--mob-safe-top) + var(--sp-4)) 0
  401. calc(var(--mob-safe-bottom) + var(--sp-3));
  402. border-radius: 0 var(--r-lg) var(--r-lg) 0;
  403. background: var(--surface);
  404. box-shadow: var(--shadow-pop);
  405. transform: translateX(-100%);
  406. transition: transform var(--motion-panel) var(--ease-standard);
  407. }
  408. .m-drawer.show {
  409. transform: none;
  410. }
  411. .m-drawer-head,
  412. .m-drawer-nav,
  413. .m-drawer-body {
  414. /* 固定区不参与收缩:否则高度不够时会被压扁 */
  415. flex: none;
  416. padding-left: var(--sp-4);
  417. padding-right: var(--sp-4);
  418. }
  419. .m-drawer-head {
  420. padding-bottom: var(--sp-3);
  421. }
  422. /*
  423. * 【2026-11-08】导航块(会话/项目/任务/通讯录/更多)固定在抽屉上部,
  424. * 不随对话列表滚动 —— 它是全局入口,滚走了就找不到。
  425. */
  426. .m-drawer-nav {
  427. padding-bottom: var(--sp-3);
  428. border-bottom: 1px solid var(--edge);
  429. }
  430. /*
  431. * 【2026-11-08】唯一滚动区:从「对话」标题到抽屉底部。
  432. *
  433. * 必须 min-height: 0:flex 子项默认 min-height:auto 会撑到内容高度,
  434. * flex:1 + overflow-y:auto 失效,溢出被抽屉裁掉(用户反馈「上半截卡住不动」)。
  435. * 顶部 padding 改由标题自身上边距承担,滚动时标题不会被切掉一半。
  436. */
  437. .m-drawer-body {
  438. flex: 1 1 auto;
  439. min-height: 0;
  440. overflow-y: auto;
  441. overscroll-behavior: contain;
  442. -webkit-overflow-scrolling: touch;
  443. }
  444. .m-entry {
  445. display: flex;
  446. align-items: center;
  447. gap: var(--sp-3);
  448. width: 100%;
  449. min-height: var(--touch-min);
  450. padding: 0 var(--sp-2);
  451. border: 0;
  452. border-radius: var(--r-sm);
  453. background: none;
  454. color: var(--ink);
  455. font: inherit;
  456. font-size: var(--fs-15);
  457. text-align: left;
  458. cursor: pointer;
  459. transition: background var(--motion-fast) var(--ease-standard);
  460. }
  461. .m-entry:active {
  462. background: var(--surface-hover);
  463. }
  464. .m-entry-lead {
  465. display: grid;
  466. place-items: center;
  467. flex: none;
  468. width: 32px;
  469. height: 32px;
  470. border-radius: var(--r-sm);
  471. background: var(--surface-2);
  472. color: var(--ink-2);
  473. font-size: var(--fs-14);
  474. }
  475. .m-entry b {
  476. flex: 1;
  477. font-weight: 500;
  478. }
  479. .m-entry--primary .m-entry-lead {
  480. background: var(--primary-subtle);
  481. color: var(--primary);
  482. }
  483. .m-entry--primary b {
  484. color: var(--primary);
  485. font-weight: 600;
  486. }
  487. /*
  488. * 「在本容器新建」(2026-11-14):次于主入口,但不能弱到看不见。
  489. * 用 surface-2 底 + 常规色文字,视觉上是一条「就近快捷方式」而非第二个主按钮,
  490. * 避免与上方「新建对话」抢主次。
  491. */
  492. .m-entry--quick {
  493. margin-top: var(--sp-1);
  494. background: var(--surface-2);
  495. font-size: var(--fs-13);
  496. }
  497. .m-entry--quick .m-entry-lead {
  498. color: var(--ink-2);
  499. }
  500. .m-entry-kbd {
  501. font-size: var(--fs-11);
  502. color: var(--muted);
  503. }
  504. .m-chev {
  505. color: var(--muted);
  506. font-size: var(--fs-12);
  507. }
  508. .m-sep {
  509. margin: var(--sp-3) 0 var(--sp-1);
  510. border-top: 1px solid var(--edge);
  511. }
  512. .m-drawer-caption {
  513. /* 滚动区自身不加 padding-top,否则滚动时「对话」标题会被切掉一半 */
  514. margin-top: var(--sp-3);
  515. padding: var(--sp-1) var(--sp-2);
  516. color: var(--muted);
  517. font-size: var(--fs-12);
  518. }
  519. .m-tree-toggle {
  520. display: flex;
  521. align-items: center;
  522. gap: var(--sp-1);
  523. width: 100%;
  524. min-height: 36px;
  525. padding: 0 var(--sp-2);
  526. border: 0;
  527. border-radius: var(--r-sm);
  528. background: none;
  529. color: var(--muted);
  530. font: inherit;
  531. font-size: var(--fs-12);
  532. font-weight: 600;
  533. cursor: pointer;
  534. }
  535. /*
  536. * 分组内的「已归档」子块(2026-11-08):比分组标题轻一档,
  537. * 缩进一点,视觉上属于上级分组而不是平级。
  538. */
  539. .m-arch-toggle {
  540. display: flex;
  541. align-items: center;
  542. gap: var(--sp-1);
  543. width: calc(100% - var(--sp-4));
  544. min-height: 32px;
  545. margin-left: var(--sp-4);
  546. padding: 0 var(--sp-2);
  547. border: 0;
  548. border-radius: var(--r-sm);
  549. background: none;
  550. color: var(--faint, var(--muted));
  551. font: inherit;
  552. font-size: var(--fs-12);
  553. cursor: pointer;
  554. }
  555. .m-arch-toggle:hover {
  556. background: var(--surface-hover);
  557. }
  558. .m-arrow {
  559. font-size: var(--fs-10);
  560. transition: transform var(--motion-fast) var(--ease-standard);
  561. }
  562. .m-tree-toggle[aria-expanded='false'] .m-arrow {
  563. transform: rotate(-90deg);
  564. }
  565. .m-tree-count {
  566. margin-left: auto;
  567. font-weight: 400;
  568. }
  569. .m-tree-item {
  570. display: flex;
  571. align-items: center;
  572. gap: var(--sp-2);
  573. width: 100%;
  574. min-height: 44px;
  575. padding: var(--sp-2);
  576. border: 0;
  577. border-radius: var(--r-sm);
  578. background: none;
  579. color: var(--ink-2);
  580. font: inherit;
  581. font-size: var(--fs-15);
  582. text-align: left;
  583. cursor: pointer;
  584. }
  585. .m-tree-item:active {
  586. background: var(--surface-hover);
  587. }
  588. .m-tree-item.on {
  589. background: var(--primary-subtle);
  590. color: var(--primary);
  591. font-weight: 600;
  592. }
  593. .m-tree-text {
  594. flex: 1;
  595. min-width: 0;
  596. display: flex;
  597. flex-direction: column;
  598. overflow: hidden;
  599. text-overflow: ellipsis;
  600. white-space: nowrap;
  601. }
  602. /* 「更多」展开区:缩进 + 浅底,与一级项区分层级 */
  603. .m-more {
  604. margin: 0 0 var(--sp-2) var(--sp-4);
  605. padding-left: var(--sp-2);
  606. border-left: 2px solid var(--edge);
  607. }
  608. .m-entry--sub {
  609. min-height: 40px;
  610. font-size: var(--fs-15);
  611. }
  612. /*
  613. * 一级菜单选中态(2026-11-04):与 PC 左栏一致 —— **整行浅底**,不只改文字色。
  614. * PC 端选中的是整行(surface-hover 底 + 主色文字),移动端此前只有文字变色,
  615. * 视觉上几乎看不出「当前在哪一页」。
  616. */
  617. .m-entry.on {
  618. background: var(--surface-hover);
  619. color: var(--primary);
  620. }
  621. .m-entry.on b {
  622. color: var(--primary);
  623. font-weight: 600;
  624. }
  625. .m-entry.on .m-entry-lead {
  626. background: var(--primary-subtle);
  627. color: var(--primary);
  628. }
  629. .m-ws-bar {
  630. display: flex;
  631. align-items: center;
  632. justify-content: space-between;
  633. padding: var(--sp-2) var(--sp-2) var(--sp-1);
  634. color: var(--muted);
  635. font-size: var(--fs-12);
  636. }
  637. /*
  638. * 🔴 所有按钮一律 white-space:nowrap(2026-11-04)。
  639. *
  640. * 工作区行里有 3 个按钮(展开/新建对话/更多),抽屉宽度只有 84%,
  641. * 文字按钮会被挤成竖排(「新/建」上下叠,用户实测)。
  642. * 现在:文字按钮固定不换行,容器 flex 不压缩按钮。
  643. */
  644. .m-ws-bar > span,
  645. .m-ws-create,
  646. .m-ws-add,
  647. .m-ws-more,
  648. .m-tree-more,
  649. .m-tree-toggle {
  650. flex: none;
  651. white-space: nowrap;
  652. }
  653. /* 新建工作区:轻量图标按钮(不要实心块,避免与行内「+」混淆) */
  654. .m-ws-create {
  655. display: grid;
  656. place-items: center;
  657. width: 28px;
  658. height: 28px;
  659. border: 0;
  660. border-radius: var(--r-sm);
  661. background: none;
  662. color: var(--primary);
  663. font-size: var(--fs-12);
  664. cursor: pointer;
  665. }
  666. .m-ws-create:active {
  667. background: var(--surface-hover);
  668. }
  669. .m-ws-row {
  670. display: flex;
  671. align-items: center;
  672. gap: var(--sp-1);
  673. }
  674. .m-ws-row .m-tree-toggle {
  675. flex: 1;
  676. min-width: 0;
  677. overflow: hidden;
  678. text-overflow: ellipsis;
  679. }
  680. .m-ws-row .m-tree-count {
  681. flex: none;
  682. }
  683. /* 在工作区中新建对话:与「⋯」同尺寸同风格,避免大小不一 */
  684. .m-ws-add {
  685. display: grid;
  686. place-items: center;
  687. flex: none;
  688. width: 26px;
  689. height: 26px;
  690. border: 0;
  691. border-radius: var(--r-sm);
  692. background: none;
  693. color: var(--muted);
  694. font-size: var(--fs-11);
  695. cursor: pointer;
  696. }
  697. .m-ws-add:active,
  698. .m-ws-more:active,
  699. .m-tree-more:active {
  700. background: var(--surface-hover);
  701. }
  702. .m-ws-more,
  703. .m-tree-more {
  704. display: grid;
  705. place-items: center;
  706. flex: none;
  707. width: 26px;
  708. height: 26px;
  709. border: 0;
  710. border-radius: var(--r-sm);
  711. background: none;
  712. color: var(--muted);
  713. cursor: pointer;
  714. }
  715. .m-tree-line {
  716. display: flex;
  717. align-items: center;
  718. gap: var(--sp-1);
  719. }
  720. .m-tree-line .m-tree-item {
  721. flex: 1;
  722. min-width: 0;
  723. }
  724. /* 底部操作菜单 */
  725. .m-act-root {
  726. position: fixed;
  727. inset: 0;
  728. /* 抽屉内弹层:高于抽屉(1100)、低于预览(1300),且盖住底栏 */
  729. z-index: var(--z-context-menu);
  730. }
  731. .m-act-mask {
  732. position: absolute;
  733. inset: 0;
  734. background: var(--overlay);
  735. }
  736. /*
  737. * 操作菜单(2026-11-03 重做样式)。
  738. * 原实现用「灰底拼块」分隔各项(gap:1px + --edge 背景),
  739. * 在浅色主题下看起来像一堆碎块,标题行也被挤压。
  740. * 改为单张白卡 + 细分割线,标题与操作区分层级。
  741. */
  742. .m-act {
  743. position: absolute;
  744. left: var(--sp-2);
  745. right: var(--sp-2);
  746. bottom: calc(var(--mob-safe-bottom) + 52px + var(--sp-3) + var(--sp-2));
  747. display: flex;
  748. flex-direction: column;
  749. overflow: hidden;
  750. border-radius: var(--r-lg);
  751. background: var(--surface);
  752. box-shadow: var(--shadow-overlay);
  753. }
  754. .m-grabber {
  755. width: 36px;
  756. height: 4px;
  757. margin: var(--sp-2) auto;
  758. border-radius: var(--r-tag);
  759. background: var(--edge-strong);
  760. }
  761. .m-act-title {
  762. margin: 0;
  763. padding: var(--sp-3) var(--sp-4);
  764. border-bottom: 1px solid var(--edge);
  765. color: var(--muted);
  766. font-size: var(--fs-12);
  767. text-align: center;
  768. overflow: hidden;
  769. text-overflow: ellipsis;
  770. white-space: nowrap;
  771. }
  772. .m-act-item {
  773. min-height: 52px;
  774. padding: 0 var(--sp-4);
  775. border: 0;
  776. border-top: 1px solid var(--edge);
  777. background: var(--surface);
  778. color: var(--ink);
  779. font: inherit;
  780. font-size: var(--fs-15);
  781. cursor: pointer;
  782. transition: background var(--motion-fast) var(--ease-standard);
  783. }
  784. /* 第一项不要顶部分割线(标题行已有下边线) */
  785. .m-act-title + .m-act-item {
  786. border-top: 0;
  787. }
  788. .m-act-item:active {
  789. background: var(--surface-hover);
  790. }
  791. .m-act-item.danger {
  792. color: var(--danger);
  793. }
  794. /* 取消独立成一张卡片(iOS 惯例),与危险操作拉开距离 */
  795. .m-act-cancel {
  796. position: absolute;
  797. left: var(--sp-2);
  798. right: var(--sp-2);
  799. bottom: calc(var(--mob-safe-bottom) + var(--sp-2));
  800. min-height: 52px;
  801. border: 0;
  802. border-radius: var(--r-lg);
  803. background: var(--surface);
  804. box-shadow: var(--shadow-overlay);
  805. color: var(--ink-2);
  806. font: inherit;
  807. font-size: var(--fs-15);
  808. font-weight: 600;
  809. cursor: pointer;
  810. }
  811. .m-tree-title,
  812. .m-tree-agent {
  813. display: block;
  814. overflow: hidden;
  815. text-overflow: ellipsis;
  816. white-space: nowrap;
  817. }
  818. .m-tree-agent {
  819. color: var(--muted);
  820. font-size: var(--fs-11);
  821. font-weight: 400;
  822. }
  823. .m-tree-item.on .m-tree-agent {
  824. color: var(--primary);
  825. opacity: 0.75;
  826. }
  827. .m-tree-group-icon {
  828. margin-right: var(--sp-1);
  829. color: var(--primary);
  830. font-size: var(--fs-10);
  831. }
  832. /* 容器状态点(2026-11-08):与 PC 左栏同尺寸 */
  833. .m-state-dot {
  834. display: inline-block;
  835. width: 6px;
  836. height: 6px;
  837. margin-right: 4px;
  838. border-radius: 50%;
  839. vertical-align: middle;
  840. background: var(--faint);
  841. }
  842. .m-state-dot[data-tone='success'] {
  843. background: var(--success);
  844. }
  845. .m-state-dot[data-tone='warning'] {
  846. background: var(--warning);
  847. }
  848. .m-state-dot[data-tone='danger'] {
  849. background: var(--danger);
  850. }
  851. .m-state-dot[data-tone='info'] {
  852. background: var(--primary);
  853. }
  854. .m-tree-empty {
  855. margin: var(--sp-1) var(--sp-2);
  856. color: var(--muted);
  857. font-size: var(--fs-12);
  858. }
  859. `,
  860. ],
  861. })
  862. export class MDrawerComponent {
  863. private readonly router = inject(Router);
  864. private readonly sessionsSvc = inject(ChatSessionService);
  865. private readonly view = inject(MobileChatViewService);
  866. private readonly live = inject(LiveService);
  867. private readonly workspacesSvc = inject(WorkspaceService);
  868. private readonly navMenu = inject(NavMenuService);
  869. private readonly prompt = inject(PromptModalService);
  870. /** 编辑菜单弹层 */
  871. protected readonly customOpen = signal(false);
  872. /** 二次确认对话框 */
  873. protected readonly confirmOpen = model<boolean>(false);
  874. private readonly confirmState = signal<{ title: string; message: string; text: string; run: () => void } | null>(null);
  875. protected readonly confirmTitle = computed(() => this.confirmState()?.title ?? '');
  876. protected readonly confirmMessage = computed(() => this.confirmState()?.message ?? '');
  877. protected readonly confirmText = computed(() => this.confirmState()?.text ?? '确认');
  878. /** 底部操作菜单(工作区 / 会话共用) */
  879. protected readonly menu = signal<{ title: string; items: MenuItem[] } | null>(null);
  880. /** 当前路由(菜单高亮用) */
  881. private readonly url = signal('');
  882. readonly open = input(false);
  883. /** 当前打开的会话 id(会话详情页高亮用) */
  884. readonly activeSessionId = input<string | null>(null);
  885. readonly close = output<void>();
  886. readonly newChat = output<void>();
  887. /**
  888. * 在**当前容器**再开一条对话(2026-11-14)。
  889. *
  890. * 聊天页把当前 agentId 传进来,抽屉顶部就多出一个「在本容器新建」快捷入口;
  891. * 其余页面不传 → `null` → 该入口不渲染,行为与之前完全一致。
  892. *
  893. * 为什么不直接放顶栏按钮:聊天页右侧已有两个按钮,再塞第三个会把
  894. * 标题区挤到 `3×44+28px` 的预留里,长标题更早被省略号截断。
  895. */
  896. readonly quickNewAgentId = input<string | null>(null);
  897. readonly quickNew = output<string>();
  898. /** 在指定工作区新建对话(payload = workspaceId) */
  899. readonly newChatWithWs = output<string>();
  900. /**
  901. * 一级菜单:来自 `NavMenuService`(与 PC 左栏共用同一份用户偏好)。
  902. *
  903. * 「会话」不在 catalog 里(它是两端固定的落地页),单独置顶;
  904. * `new-chat` 是动作项、不是路由,由抽屉顶部的「新建对话」承担,这里过滤掉。
  905. */
  906. /** 直接展示的菜单项数量(与 PC 左栏同值) */
  907. private static readonly PRIMARY_COUNT = 4;
  908. protected readonly navItems = computed(() => {
  909. const fixed: NavRow = {
  910. key: 'messages',
  911. path: '/messages',
  912. label: '会话',
  913. icon: 'fa-solid fa-comment',
  914. active: this.url() === '/messages',
  915. };
  916. const rows: NavRow[] = this.navMenu
  917. .visible()
  918. .filter((i) => i.key !== 'new-chat' && i.path)
  919. .map((i) => ({
  920. key: i.key,
  921. path: i.path!,
  922. label: i.label,
  923. icon: `fa-solid ${i.icon}`,
  924. active: this.url() === i.path,
  925. }));
  926. return [fixed, ...rows];
  927. });
  928. /**
  929. * 前 4 项直显、其余进「更多」。
  930. *
  931. * 「会话」固定在首位且**计入 4 项**(它是最常用的入口),
  932. * 与 PC 左栏「前 4 项 + 更多」的语义一致。
  933. */
  934. protected readonly primaryNav = computed(() =>
  935. this.navItems().slice(0, MNavConstants.PRIMARY_COUNT),
  936. );
  937. protected readonly moreNav = computed(() =>
  938. this.navItems().slice(MNavConstants.PRIMARY_COUNT),
  939. );
  940. /** 「更多」是否展开 */
  941. protected readonly moreOpen = signal(false);
  942. /** 全部工作区(**含空工作区**,与 PC 一致;原实现过滤掉了没有会话的) */
  943. protected readonly workspaces = computed(() => this.workspacesSvc.sorted());
  944. protected readonly daily = computed(() => this.view.dailySessions());
  945. /** 群会话(2026-11-05) */
  946. protected readonly groupSessions = computed(() => this.view.groupSessions());
  947. /** 全部已归档(时间倒序) */
  948. protected readonly archived = this.view.archived;
  949. /*
  950. * 已归档会话按**原分组**切开(2026-11-08 用户反馈):
  951. * 归档后要回到群聊 / 工作区 / 日常各自的分组下,而不是统一堆在底部一个「已归档」里。
  952. * 判据来自 `groupOf()`,与桌面端共用。
  953. */
  954. private readonly archivedGroups = this.view.archivedGroups;
  955. protected readonly archivedDaily = computed(() => this.archivedGroups().daily);
  956. protected readonly archivedGroup = computed(() => this.archivedGroups().group);
  957. /** 某工作区下的已归档会话 */
  958. protected archivedIn(wsId: string): SessionRow[] {
  959. return this.archivedGroups().byWorkspace.get(wsId) ?? [];
  960. }
  961. /** 会话所属容器的状态(2026-11-08):与 PC 左栏 / 会话列表同一口径 */
  962. protected stateOf(row: SessionRow): AgentStateView {
  963. return resolveAgentState({ agent: row.agent });
  964. }
  965. constructor() {
  966. /*
  967. * 工作区必须在这里 load():原实现**全仓只有桌面左栏**调过,
  968. * 移动端从不加载 → 云端工作区永远拉不下来,
  969. * 表现为「工作区筛选永远为空」(用户实测)。
  970. */
  971. this.workspacesSvc.load();
  972. this.navMenu.load();
  973. this.url.set(this.router.url.replace(/^\/mobile/, '').split('?')[0]);
  974. this.router.events
  975. .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
  976. .subscribe((e) => this.url.set(e.urlAfterRedirects.replace(/^\/mobile/, '').split('?')[0]));
  977. /*
  978. * 会话目录预热。
  979. *
  980. * 🔴 effect 只能依赖 `open()`:`ensureLoaded()` 内部会读 `live.agents()`,
  981. * 若在 effect 里直接调用,agents 每次轮询刷新(15s)都会重新触发本 effect →
  982. * 反复请求会话目录云函数(实测 12 秒内 13 次)。
  983. * 这里用 untracked 隔断依赖,只在抽屉「打开」这一次真正执行。
  984. */
  985. effect(() => {
  986. if (!this.open()) return;
  987. untracked(() => this.ensureLoaded());
  988. });
  989. }
  990. /**
  991. * 折叠状态:键为工作区 id;`__daily` / `__group` 为固定分组;
  992. * `arch-*` 前缀为各分组下的「已归档」子块(2026-11-08 新增,默认收起)。
  993. */
  994. private readonly expanded = signal<Record<string, boolean>>({ __daily: true, __group: true });
  995. protected isOpen(key: string): boolean {
  996. // 归档子块默认收起:没显式点过就当关闭
  997. return this.expanded()[key] ?? !key.startsWith('arch-');
  998. }
  999. /** 只切 `hidden` 与箭头,不重建列表(Demo 同法,避免整块重绘跳动) */
  1000. protected toggle(key: string): void {
  1001. const cur = this.isOpen(key);
  1002. this.expanded.update((m) => ({ ...m, [key]: !cur }));
  1003. }
  1004. protected rowsInWorkspace(wsId: string): SessionRow[] {
  1005. return this.view.active().filter((r) => r.session.workspaceId === wsId);
  1006. }
  1007. protected go(path: string): void {
  1008. this.close.emit();
  1009. void this.router.navigateByUrl(`/mobile${path}`);
  1010. }
  1011. /**
  1012. * 打开会话(2026-11-05 区分群聊)。
  1013. *
  1014. * 群会话指向独立的群聊页 `/mobile/group/:groupId`;
  1015. * 单聊仍是 `/mobile/chat/:agentId/:sessionId`。
  1016. */
  1017. protected goChat(row: { session: { id: string; agentId: string; kind?: 'dm' | 'group' } }): void {
  1018. this.close.emit();
  1019. const s = row.session;
  1020. void this.router.navigateByUrl(
  1021. s.kind === 'group'
  1022. ? `/mobile/group/${encodeURIComponent(s.id)}`
  1023. : `/mobile/chat/${s.agentId}/${encodeURIComponent(s.id)}`,
  1024. );
  1025. }
  1026. // ---------- 工作区操作 ----------
  1027. /** 新建工作区(重名校验与 PC 一致) */
  1028. protected newWorkspace(): void {
  1029. this.prompt.open({
  1030. title: '新建工作区',
  1031. placeholder: '工作区名称',
  1032. value: '',
  1033. allowEmpty: false,
  1034. confirmText: '创建',
  1035. validate: (v) => {
  1036. const n = v.trim();
  1037. if (!n) return '名称不能为空';
  1038. if (this.workspacesSvc.hasName(n)) return '已存在同名工作区';
  1039. return null;
  1040. },
  1041. onConfirm: (v) => {
  1042. this.workspacesSvc.create(v.trim());
  1043. },
  1044. });
  1045. }
  1046. /** 工作区「⋯」:重命名 / 删除 */
  1047. protected openWsMenu(id: string): void {
  1048. const ws = this.workspacesSvc.sorted().find((w) => w.id === id);
  1049. if (!ws) return;
  1050. const count = this.rowsInWorkspace(id).length;
  1051. this.menu.set({
  1052. title: ws.name,
  1053. items: [
  1054. { label: '重命名', run: () => this.renameWorkspace(id) },
  1055. {
  1056. label: `删除工作区${count > 0 ? `(${count} 个会话移到日常对话)` : ''}`,
  1057. danger: true,
  1058. run: () => this.deleteWorkspace(id),
  1059. },
  1060. ],
  1061. });
  1062. }
  1063. private renameWorkspace(id: string): void {
  1064. const ws = this.workspacesSvc.sorted().find((w) => w.id === id);
  1065. if (!ws) return;
  1066. this.prompt.open({
  1067. title: '重命名工作区',
  1068. value: ws.name,
  1069. placeholder: '工作区名称',
  1070. allowEmpty: false,
  1071. confirmText: '保存',
  1072. validate: (v) => {
  1073. const n = v.trim();
  1074. if (!n) return '名称不能为空';
  1075. if (this.workspacesSvc.hasName(n, id)) return '已存在同名工作区';
  1076. return null;
  1077. },
  1078. onConfirm: (v) => {
  1079. this.workspacesSvc.rename(id, v.trim());
  1080. },
  1081. });
  1082. }
  1083. /**
  1084. * 删除工作区:先把其下会话退回「日常对话」,再删工作区。
  1085. * 与桌面 `doDeleteWorkspace`(session-tree.ts)同口径 —— 否则会话会失去归属变成孤儿。
  1086. */
  1087. private deleteWorkspace(id: string): void {
  1088. for (const s of this.sessionsSvc.all().filter((x) => x.workspaceId === id)) {
  1089. this.sessionsSvc.moveTo(s.id, null);
  1090. }
  1091. this.workspacesSvc.remove(id);
  1092. }
  1093. // ---------- 会话操作 ----------
  1094. /** 会话「⋯」:移动到工作区 / 归档 / 删除 */
  1095. protected openSessionMenu(row: SessionRow): void {
  1096. const items: MenuItem[] = [
  1097. {
  1098. label: row.session.workspaceId ? '移出到日常对话' : '移动到工作区',
  1099. run: () => this.openMoveMenu(row),
  1100. },
  1101. {
  1102. label: row.session.archived ? '取消归档' : '归档',
  1103. // 归档也要二次确认(与 PC 一致)
  1104. run: () =>
  1105. row.session.archived
  1106. ? this.sessionsSvc.archive(row.session.id, false)
  1107. : this.ask({
  1108. title: '归档会话',
  1109. message: `归档后「${row.session.title}」将移到本分组的「已归档」,可随时取消归档。`,
  1110. text: '归档',
  1111. run: () => {
  1112. this.sessionsSvc.archive(row.session.id, true);
  1113. /*
  1114. * 归档后自动展开**该会话原分组**的「已归档」子块 ——
  1115. * 让用户看到它去了哪里,而不是以为消失了。
  1116. */
  1117. this.revealArchived(row);
  1118. },
  1119. }),
  1120. },
  1121. {
  1122. label: '删除会话',
  1123. danger: true,
  1124. // 删除不可恢复,必须二次确认(与 PC 一致)
  1125. run: () =>
  1126. this.ask({
  1127. title: '删除会话',
  1128. message: `确定删除「${row.session.title}」?删除后该会话不再出现在列表里,消息记录仍在服务器。`,
  1129. text: '删除',
  1130. run: () => this.sessionsSvc.remove(row.session.id),
  1131. }),
  1132. },
  1133. ];
  1134. this.menu.set({ title: row.session.title, items });
  1135. }
  1136. /**
  1137. * 展开某会话「原分组」下的已归档子块(2026-11-08)。
  1138. *
  1139. * 归档后用户需要**看到它去了哪里**;分组本身若收起着,也一并展开。
  1140. * 键与模板里的 `archBlock` 一致:`arch-<wsId>` / `arch-group` / `arch-daily`。
  1141. */
  1142. private revealArchived(row: SessionRow): void {
  1143. const g = groupOf(row.session);
  1144. const archKey = g.kind === 'workspace' ? `arch-${g.id}` : `arch-${g.kind}`;
  1145. const parentKey = g.kind === 'workspace' ? g.id : `__${g.kind}`;
  1146. this.expanded.update((m) => ({ ...m, [parentKey]: true, [archKey]: true }));
  1147. }
  1148. /** 二级:选择目标工作区 */
  1149. private openMoveMenu(row: SessionRow): void { if (row.session.workspaceId) {
  1150. this.sessionsSvc.moveTo(row.session.id, null);
  1151. return;
  1152. }
  1153. const ws = this.workspacesSvc.sorted();
  1154. if (ws.length === 0) {
  1155. this.newWorkspace();
  1156. return;
  1157. }
  1158. this.menu.set({
  1159. title: '移动到工作区',
  1160. items: ws.map((w) => ({
  1161. label: w.name,
  1162. run: () => this.sessionsSvc.moveTo(row.session.id, w.id),
  1163. })),
  1164. });
  1165. }
  1166. /** 在指定工作区中新建对话:带上 workspaceId 交给页面处理 */
  1167. protected newChatIn(workspaceId: string): void {
  1168. this.close.emit();
  1169. this.newChatWithWs.emit(workspaceId);
  1170. }
  1171. /** 弹出二次确认 */
  1172. private ask(opts: { title: string; message: string; text: string; run: () => void }): void {
  1173. this.confirmState.set(opts);
  1174. this.confirmOpen.set(true);
  1175. }
  1176. protected runConfirm(): void {
  1177. this.confirmOpen.set(false);
  1178. const fn = this.confirmState()?.run;
  1179. this.confirmState.set(null);
  1180. fn?.();
  1181. }
  1182. protected runMenu(item: MenuItem): void {
  1183. this.menu.set(null);
  1184. item.run();
  1185. }
  1186. /** 抽屉首次打开时预热会话目录(与桌面左栏同一口径) */
  1187. ensureLoaded(): void {
  1188. if (this.view.ready()) return;
  1189. const ids = this.live
  1190. .agents()
  1191. .filter((a: Agent) => isMatrixReady(a))
  1192. .map((a) => a.id);
  1193. if (ids.length > 0) void this.sessionsSvc.reloadAll(ids);
  1194. }
  1195. }