session-tree.ts 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461
  1. import { NgTemplateOutlet } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, input, output, signal } from '@angular/core';
  3. import { NavigationEnd, Router, RouterLink } from '@angular/router';
  4. import { effect } from '@angular/core';
  5. import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
  6. import { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
  7. import { ChatSession, groupOf } from '../../../core/chat-session';
  8. import { ChatSessionService } from '../../../core/chat-session.service';
  9. import { Workspace, WorkspaceService } from '../../../core/workspace.service';
  10. import { PromptModalService } from '../prompt-modal/prompt-modal.service';
  11. import { NavMenuService } from '../../../core/nav-menu.service';
  12. import { HoverScrollDirective } from './hover-scroll.directive';
  13. import { ChatFontService } from '../../../core/chat-font.service';
  14. import { NavCustomModalService } from '../nav-custom-modal/nav-custom-modal.service';
  15. import { NewSessionModalService } from '../new-session-modal/new-session-modal.service';
  16. import { activeSessionIdOf } from './active-session';
  17. import { DeviceService } from '../../../core/device.service';
  18. import { LiveService } from '../../../core/live.service';
  19. import { PALETTES, PaletteService } from '../../../core/palette.service';
  20. import { SessionService } from '../../../core/session.service';
  21. import { canManageCompany } from '../../../core/acl';
  22. import { ThemeService } from '../../../ui/theme';
  23. import { UnreadService } from '../../../core/unread.service';
  24. import { ChatService } from '../../../core/chat.service';
  25. import { agentStatusTone } from '../../../core/api/models';
  26. import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
  27. /** 工作区(用户手动新建的文件夹) */
  28. /** 一次「选择容器并新建对话」的请求(由页面弹模态框处理) */
  29. export interface NewSessionRequest {
  30. /** 目标工作区;`null` = 日常对话 */
  31. workspaceId: string | null;
  32. }
  33. /**
  34. * 桌面·三段式会话树(工作区 / 日常对话 / 已归档)+ 底部用户卡
  35. *
  36. * 依据 `docs/02-design/plans/2026-10-29-桌面布局改造-顶部导航与三段式会话树.md`:
  37. * · D2 工作区 = 用户手动新建的文件夹(自取名 / 重命名 / 删除)
  38. * · D4 日常对话 = 未归入工作区的会话,单一平铺
  39. * · D5 各自归档:每个工作区内部、日常对话内部各有一份「已归档」
  40. * · D6 用户卡在树底部,弹出:账号 / 主题 / 设置
  41. *
  42. * ## 🔴 本轮(第 1 轮)范围:只做布局骨架
  43. *
  44. * 工作区是**组件内内存态**(`signal<Workspace[]>`),刷新即丢失;
  45. * `ChatSession.workspaceId` 尚未落库,因此所有会话当前都归在「日常对话」下。
  46. * 持久化与「会话→工作区」归属在第 3 轮接入 —— 接口(输入/输出)已按最终形态定义,
  47. * 第 3 轮只换数据源,模板不用改。
  48. */
  49. @Component({
  50. selector: 'app-session-tree',
  51. changeDetection: ChangeDetectionStrategy.OnPush,
  52. imports: [
  53. NgTemplateOutlet,
  54. RouterLink,
  55. UiAvatar,
  56. UiButton,
  57. UiEmpty,
  58. UiInput,
  59. UiModal,
  60. UiSkeleton,
  61. UiSwitch,
  62. UiTooltip,
  63. HoverScrollDirective,
  64. ],
  65. template: `
  66. <nav class="tree" aria-label="会话目录">
  67. <!--
  68. ========== 品牌区(2026-10-30 布局重做) ==========
  69. 顶部导航栏整体取消后,品牌信息落到左栏最上方。
  70. -->
  71. <div class="brand-row">
  72. <a class="brand" [routerLink]="link('/chat')" aria-label="Fmode 首页">
  73. <!--
  74. 品牌文字左对齐到下方菜单图标(2026-10-30 用户要求):
  75. 去掉图标后给一个等宽占位,让 "Fmode" 的起点与「新建对话」的图标列对齐。
  76. -->
  77. <span class="brand-word">Fmode</span>
  78. </a>
  79. <!--
  80. 折叠按钮(2026-10-30 用户要求"隐藏式折叠"):
  81. 悬停品牌行才出现,不常驻占位 —— 常驻会把品牌区撑宽、也和「更多」的箭头重复。
  82. -->
  83. <button
  84. type="button"
  85. class="collapse-btn"
  86. aria-label="收起左侧栏"
  87. title="收起左侧栏"
  88. (click)="collapse.emit()"
  89. >
  90. <i class="fa-solid fa-angles-left" aria-hidden="true"></i>
  91. </button>
  92. </div>
  93. <!--
  94. ========== 一级菜单(2026-10-30 可由用户在「更多 → 自定义菜单栏」里调整) ==========
  95. 数据源是 NavMenuService 的 visible():
  96. · 前 primaryCount 项直接列出
  97. · 其余进「更多」浮层
  98. 动作型项(path 为 null,如「新建对话」)点击不跳路由,而是触发动作。
  99. -->
  100. <nav class="routes" aria-label="主导航">
  101. @for (item of primaryNav(); track item.key) {
  102. @if (item.path) {
  103. <a
  104. class="route"
  105. [routerLink]="link(item.path)"
  106. [class.active]="isActive(item.path)"
  107. [attr.aria-current]="isActive(item.path) ? 'page' : null"
  108. >
  109. <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
  110. <span class="route-label">{{ item.label }}</span>
  111. @if (item.path && badgeFor(item.path) > 0) {
  112. <span class="route-badge">{{ badgeFor(item.path) }}</span>
  113. }
  114. </a>
  115. } @else {
  116. <!-- 动作项:新建对话 -->
  117. <button type="button" class="route route-action" (click)="newSession.emit(null)">
  118. <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
  119. <span class="route-label">{{ item.label }}</span>
  120. </button>
  121. }
  122. }
  123. <!-- 「更多」:收纳其余项,点击展开浮层 -->
  124. @if (moreNav().length > 0) {
  125. <button
  126. type="button"
  127. class="route route-more"
  128. [class.active]="moreActive() || moreOpen()"
  129. [attr.aria-expanded]="moreOpen()"
  130. aria-haspopup="menu"
  131. (click)="toggleMore($event)"
  132. >
  133. <i class="fa-solid fa-grip" aria-hidden="true"></i>
  134. <span class="route-label">更多</span>
  135. <i class="fa-solid fa-chevron-down more-caret" aria-hidden="true"></i>
  136. </button>
  137. }
  138. </nav>
  139. @if (moreOpen()) {
  140. <div class="menu-backdrop" aria-hidden="true" (click)="moreOpen.set(false)"></div>
  141. <div
  142. class="more-menu"
  143. role="menu"
  144. aria-label="更多入口"
  145. [style.top.px]="moreMenuPos().y"
  146. [style.left.px]="moreMenuPos().x"
  147. >
  148. @for (item of moreNav(); track item.key) {
  149. @if (item.path) {
  150. <a
  151. class="more-item"
  152. role="menuitem"
  153. [routerLink]="link(item.path)"
  154. [class.active]="isActive(item.path)"
  155. (click)="moreOpen.set(false)"
  156. >
  157. <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
  158. <span class="route-label">{{ item.label }}</span>
  159. </a>
  160. } @else {
  161. <button
  162. type="button"
  163. class="more-item"
  164. role="menuitem"
  165. (click)="moreOpen.set(false); newSession.emit(null)"
  166. >
  167. <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
  168. <span class="route-label">{{ item.label }}</span>
  169. </button>
  170. }
  171. }
  172. <hr class="more-line" />
  173. <!-- 自定义菜单栏:拖拽排序 + 显隐 -->
  174. <button
  175. type="button"
  176. class="more-item"
  177. role="menuitem"
  178. (click)="moreOpen.set(false); navCustom.open()"
  179. >
  180. <i class="fa-solid fa-sliders" aria-hidden="true"></i>
  181. <span class="route-label">自定义菜单栏</span>
  182. </button>
  183. </div>
  184. }
  185. <div class="tree-scroll">
  186. <!-- ========== 工作区 ========== -->
  187. <section class="sect">
  188. <header class="sect-head">
  189. <!-- 对齐槽:与「日常对话」的折叠箭头同宽,三个分组标题文字左缘一致(2026-11-06) -->
  190. <span class="sect-lead" aria-hidden="true">
  191. <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
  192. </span>
  193. <span class="sect-name">工作区</span>
  194. <span class="sect-count">{{ workspaces().length }}</span>
  195. <ui-tooltip content="新建工作区">
  196. <ui-button
  197. variant="ghost"
  198. shape="circle"
  199. size="sm"
  200. icon="fa-solid fa-plus"
  201. ariaLabel="新建工作区"
  202. (click)="beginNewWorkspace()"
  203. />
  204. </ui-tooltip>
  205. </header>
  206. @if (workspaces().length === 0) {
  207. <button type="button" class="empty-add" (click)="beginNewWorkspace()">
  208. <i class="fa-solid fa-plus" aria-hidden="true"></i>
  209. 新建工作区,按项目分开存放对话
  210. </button>
  211. }
  212. <div class="list-limit" [class.more-below]="wsOverflow()">
  213. @for (w of workspaces(); track w.id) {
  214. <div class="ws">
  215. <div
  216. class="ws-head"
  217. role="button"
  218. tabindex="0"
  219. [attr.aria-expanded]="isWsOpen(w.id)"
  220. (click)="toggleWorkspace(w.id)"
  221. (keydown.enter)="toggleWorkspace(w.id)"
  222. >
  223. <i
  224. class="caret fa-solid"
  225. [class.fa-chevron-right]="!isWsOpen(w.id)"
  226. [class.fa-chevron-down]="isWsOpen(w.id)"
  227. aria-hidden="true"
  228. ></i>
  229. <i class="fa-solid fa-folder" aria-hidden="true"></i>
  230. <span class="ws-name ellipsis" [title]="w.name">{{ w.name }}</span>
  231. <span class="ws-acts">
  232. <ui-tooltip content="在本工作区新建对话">
  233. <ui-button
  234. variant="ghost"
  235. shape="circle"
  236. size="sm"
  237. icon="fa-solid fa-plus"
  238. [ariaLabel]="'在 ' + w.name + ' 下新建对话'"
  239. (click)="newSessionIn(w.id, $event)"
  240. />
  241. </ui-tooltip>
  242. <ui-tooltip content="重命名">
  243. <ui-button
  244. variant="ghost"
  245. shape="circle"
  246. size="sm"
  247. icon="fa-solid fa-pen"
  248. [ariaLabel]="'重命名工作区 ' + w.name"
  249. (click)="beginRenameWorkspace(w, $event)"
  250. />
  251. </ui-tooltip>
  252. <ui-tooltip content="删除">
  253. <ui-button
  254. variant="ghost"
  255. shape="circle"
  256. size="sm"
  257. icon="fa-solid fa-trash"
  258. [ariaLabel]="'删除工作区 ' + w.name"
  259. (click)="deleteWorkspace(w.id, $event)"
  260. />
  261. </ui-tooltip>
  262. </span>
  263. </div>
  264. @if (isWsOpen(w.id)) {
  265. <div class="ws-body">
  266. @if (sessionsIn(w.id).length === 0) {
  267. <p class="sect-hint inset">还没有对话</p>
  268. }
  269. @for (s of sessionsIn(w.id); track s.id) {
  270. <ng-container
  271. *ngTemplateOutlet="row; context: { $implicit: s, owner: w.id }"
  272. ></ng-container>
  273. }
  274. @if (archivedIn(w.id).length > 0) {
  275. <button
  276. type="button"
  277. class="arch-toggle"
  278. [attr.aria-expanded]="isArchOpen(w.id)"
  279. (click)="toggleArchiveGroup(w.id)"
  280. >
  281. <i
  282. class="fa-solid"
  283. [class.fa-chevron-right]="!isArchOpen(w.id)"
  284. [class.fa-chevron-down]="isArchOpen(w.id)"
  285. aria-hidden="true"
  286. ></i>
  287. 已归档 <span class="arch-count">{{ archivedIn(w.id).length }}</span>
  288. </button>
  289. @if (isArchOpen(w.id)) {
  290. @for (s of archivedIn(w.id); track s.id) {
  291. <ng-container
  292. *ngTemplateOutlet="row; context: { $implicit: s, owner: w.id }"
  293. ></ng-container>
  294. }
  295. }
  296. }
  297. </div>
  298. }
  299. </div>
  300. }
  301. </div>
  302. </section>
  303. <!-- ========== 群聊(2026-11-05):夹在工作区与日常之间 ========== -->
  304. <!--
  305. 群聊分组头与「工作区」同构(2026-11-06 用户要求):
  306. 标题在左、计数靠右、最右侧「+」新建群聊。此前是「大标题 + 计数字」,
  307. 和上下两个分组头排版不一致,看起来像另一套组件。
  308. 群聊不归属工作区,也没有「移动到工作区」,所以不必做可折叠 ——
  309. 折叠箭头留给有下一级归属的「日常对话」。
  310. -->
  311. <section class="sect sect--group">
  312. <header class="sect-head">
  313. <span class="sect-lead" aria-hidden="true">
  314. <i class="fa-solid fa-users" aria-hidden="true"></i>
  315. </span>
  316. <span class="sect-name">群聊</span>
  317. <span class="sect-count">{{ groupSessions().length }}</span>
  318. <ui-tooltip content="新建群聊">
  319. <ui-button
  320. variant="ghost"
  321. shape="circle"
  322. size="sm"
  323. icon="fa-solid fa-plus"
  324. ariaLabel="新建群聊"
  325. (click)="beginNewGroup()"
  326. />
  327. </ui-tooltip>
  328. </header>
  329. <!-- 群列表与工作区/日常同一套内部滚动:分组再多也不会把下面的分组顶出可视区 -->
  330. <div class="list-limit" [class.more-below]="groupOverflow()">
  331. @if (groupSessions().length === 0) {
  332. <button type="button" class="empty-add" (click)="beginNewGroup()">
  333. <i class="fa-solid fa-plus" aria-hidden="true"></i>
  334. 新建群聊,把多个数字生命拉进同一个对话
  335. </button>
  336. }
  337. @for (s of groupSessions(); track s.id) {
  338. <ng-container
  339. *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"
  340. />
  341. }
  342. <!--
  343. 群聊的「已归档」(2026-11-08 用户反馈):归档是「收起来」,不是「搬走」。
  344. 原实现把群聊归档塞进「日常对话 → 已归档」,用户找不到。
  345. -->
  346. @if (groupArchived().length > 0) {
  347. <button
  348. type="button"
  349. class="arch-toggle"
  350. [attr.aria-expanded]="isArchOpen('__group__')"
  351. (click)="toggleArchiveGroup('__group__')"
  352. >
  353. <i
  354. class="fa-solid"
  355. [class.fa-chevron-right]="!isArchOpen('__group__')"
  356. [class.fa-chevron-down]="isArchOpen('__group__')"
  357. aria-hidden="true"
  358. ></i>
  359. 已归档 <span class="arch-count">{{ groupArchived().length }}</span>
  360. </button>
  361. @if (isArchOpen('__group__')) {
  362. @for (s of groupArchived(); track s.id) {
  363. <ng-container
  364. *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"
  365. />
  366. }
  367. }
  368. }
  369. </div>
  370. </section>
  371. <!-- ========== 日常对话(默认展开、可折叠、与工作区平分剩余高度) ========== -->
  372. <section class="sect" [class.grow]="dailyOpen()">
  373. <header class="sect-head">
  374. <button
  375. type="button"
  376. class="sect-toggle"
  377. [attr.aria-expanded]="dailyOpen()"
  378. (click)="dailyOpen.set(!dailyOpen())"
  379. >
  380. <span class="sect-lead">
  381. <i
  382. class="caret fa-solid"
  383. [class.fa-chevron-right]="!dailyOpen()"
  384. [class.fa-chevron-down]="dailyOpen()"
  385. aria-hidden="true"
  386. ></i>
  387. </span>
  388. <span class="sect-name">日常对话</span>
  389. <span class="sect-count">{{ daily().length }}</span>
  390. </button>
  391. </header>
  392. @if (dailyOpen()) {
  393. <div class="list-limit" [class.more-below]="dailyOverflow()">
  394. @if (live.state() === 'loading' && daily().length === 0) {
  395. <div class="skel">
  396. @for (i of [1, 2, 3]; track i) {
  397. <ui-skeleton variant="rect" height="32px" />
  398. }
  399. </div>
  400. } @else if (!sessionsReady()) {
  401. <!--
  402. 会话目录拉取失败(2026-11-01 用户裁决):**不显示任何会话**。
  403. 这里必须与「还没有对话」区分开 —— 空态意味着「你确实没有」,
  404. 而实际是「我们不知道你有没有」。混为一谈会让用户以为对话被删了,
  405. 从而重复新建,进一步制造脏数据。
  406. -->
  407. <div class="sect-error" role="alert">
  408. <p>会话目录加载失败,暂时无法显示对话。</p>
  409. <p class="sect-error-detail">{{ sessionsError() }}</p>
  410. <button type="button" class="retry" (click)="retrySessions()">
  411. <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> 重试
  412. </button>
  413. </div>
  414. } @else if (daily().length === 0) {
  415. <p class="sect-hint">日常新建的对话会出现在这里。</p>
  416. }
  417. <!--
  418. 新建会话推送失败提示(2026-11-09 用户要求「推送失败要报错」)。
  419. 原来静默吞掉:会话只存在于本机,换浏览器永远看不到,用户毫不知情。
  420. -->
  421. @if (pushFailed(); as pf) {
  422. <div class="sect-error" role="alert">
  423. <p>{{ pf }}</p>
  424. <button type="button" class="retry" (click)="retryPush()">
  425. <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> 重新同步
  426. </button>
  427. </div>
  428. }
  429. @for (s of daily(); track s.id) {
  430. <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
  431. }
  432. <!--
  433. 孤儿归属兜底(2026-11-01):归属于某个已不存在的工作区。
  434. 不单独列出的话,这些会话在界面上彻底消失,用户会以为对话丢了。
  435. 点开仍可正常进入,移动工作区即可修正归属。
  436. -->
  437. @if (orphanSessions().length > 0) {
  438. <p class="sect-hint orphan-hint">
  439. 以下 {{ orphanSessions().length }} 条对话所属的工作区已不存在,可移动到其它工作区:
  440. </p>
  441. @for (s of orphanSessions(); track s.id) {
  442. <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
  443. }
  444. }
  445. @if (dailyArchived().length > 0) {
  446. <button
  447. type="button"
  448. class="arch-toggle"
  449. [attr.aria-expanded]="isArchOpen('__daily__')"
  450. (click)="toggleArchiveGroup('__daily__')"
  451. >
  452. <i
  453. class="fa-solid"
  454. [class.fa-chevron-right]="!isArchOpen('__daily__')"
  455. [class.fa-chevron-down]="isArchOpen('__daily__')"
  456. aria-hidden="true"
  457. ></i>
  458. 已归档 <span class="arch-count">{{ dailyArchived().length }}</span>
  459. </button>
  460. @if (isArchOpen('__daily__')) {
  461. @for (s of dailyArchived(); track s.id) {
  462. <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
  463. }
  464. }
  465. }
  466. </div>
  467. }
  468. </section>
  469. </div>
  470. <!-- ========== 底部用户卡(D6) ========== -->
  471. <div class="user-card">
  472. <button
  473. type="button"
  474. class="user-main"
  475. [attr.aria-expanded]="userMenuOpen()"
  476. aria-haspopup="menu"
  477. (click)="userMenuOpen.set(!userMenuOpen())"
  478. >
  479. <span class="user-avatar">
  480. <ui-avatar [name]="meName()" [size]="'sm'" />
  481. <!-- 在线状态点:账号区需要一眼看出"连得上还是掉线了" -->
  482. <span class="user-dot" [class.off]="live.state() !== 'ready'" aria-hidden="true"></span>
  483. </span>
  484. <span class="user-text">
  485. <span class="user-name ellipsis">{{ meName() }}</span>
  486. <span class="user-role ellipsis">{{ userRole() }}</span>
  487. </span>
  488. <!--
  489. 箭头方向由 CSS 控制(见 .user-main:hover > .dots):
  490. 默认朝上,悬停或菜单展开时朝下。不在模板里切类名,
  491. 避免「模板状态」与「悬停状态」两套逻辑打架。
  492. -->
  493. <i class="fa-solid fa-chevron-up dots" aria-hidden="true"></i>
  494. </button>
  495. <!-- 点外部关闭 -->
  496. @if (userMenuOpen()) {
  497. <div class="menu-backdrop" aria-hidden="true" (click)="userMenuOpen.set(false)"></div>
  498. <div class="user-menu" role="menu">
  499. <!--
  500. 明暗开关:用 ui-switch 直接表达「开/关」状态,
  501. 比「点一下切到下一边」的按钮更符合用户对主题的认知(当前已经是深色还是浅色,一眼可见)。
  502. -->
  503. <div class="menu-row">
  504. <i class="fa-solid fa-circle-half-stroke" aria-hidden="true"></i>
  505. <span class="menu-row-text">深色模式</span>
  506. <!-- ui-switch 内部自带 click → toggle;用 model 双向绑定回写即为唯一入口 -->
  507. <ui-switch
  508. [checked]="theme.isDark()"
  509. size="sm"
  510. ariaLabel="深色模式"
  511. (checkedChange)="theme.setMode($event ? 'dark' : 'light')"
  512. />
  513. </div>
  514. <p class="menu-label">配色</p>
  515. <!--
  516. 配色项(2026-10-30 按 demo 优化):
  517. 原来是「灰圆点 + 名称」,未选中项那排黑圆点非常抢眼、且不表达任何信息。
  518. 改成 demo 的做法:**色块**(真实取色)+ 名称,选中才出现对勾。
  519. -->
  520. @for (p of palettes; track p.id) {
  521. <button
  522. type="button"
  523. class="swatch-item"
  524. role="menuitemradio"
  525. [attr.aria-checked]="palette.palette() === p.id"
  526. (click)="palette.set(p.id)"
  527. >
  528. <span class="swatch" [attr.data-preview-palette]="p.id" aria-hidden="true">
  529. <span class="swatch-in"></span>
  530. </span>
  531. <span class="swatch-name">{{ p.name }}</span>
  532. @if (palette.palette() === p.id) {
  533. <i class="fa-solid fa-check swatch-check" aria-hidden="true"></i>
  534. }
  535. </button>
  536. }
  537. <hr class="menu-line" />
  538. <button type="button" class="menu-item" role="menuitem" (click)="goSettings()">
  539. <i class="fa-solid fa-gear" aria-hidden="true"></i>账号与设置
  540. </button>
  541. </div>
  542. }
  543. </div>
  544. <!--
  545. 移动到工作区的浮层菜单。
  546. 用 fixed 定位而不是 absolute:侧栏内容区是 overflow:auto 的滚动容器,
  547. absolute 弹层会被裁掉(规范 §三-13 明确要求浮层走 fixed)。
  548. -->
  549. @if (movingSession(); as mv) {
  550. <div class="move-backdrop" aria-hidden="true" (click)="movingSession.set(null)"></div>
  551. <div
  552. class="move-menu"
  553. role="menu"
  554. [style.left.px]="moveMenuPos().x"
  555. [style.top.px]="moveMenuPos().y"
  556. >
  557. <p class="menu-label">移动到</p>
  558. <button
  559. type="button"
  560. class="menu-item"
  561. role="menuitemradio"
  562. [attr.aria-checked]="!mv.workspaceId"
  563. (click)="moveSession(mv, null)"
  564. >
  565. <i class="fa-solid" [class.fa-check]="!mv.workspaceId" [class.fa-minus]="!!mv.workspaceId" aria-hidden="true"></i>
  566. 日常对话
  567. </button>
  568. @for (w of workspaces(); track w.id) {
  569. <button
  570. type="button"
  571. class="menu-item"
  572. role="menuitemradio"
  573. [attr.aria-checked]="mv.workspaceId === w.id"
  574. (click)="moveSession(mv, w.id)"
  575. >
  576. <i class="fa-solid" [class.fa-check]="mv.workspaceId === w.id" [class.fa-minus]="mv.workspaceId !== w.id" aria-hidden="true"></i>
  577. {{ w.name }}
  578. </button>
  579. }
  580. </div>
  581. }
  582. </nav>
  583. <!-- 删除工作区确认(2026-11-01) -->
  584. <ui-modal
  585. [open]="removingWorkspace() !== null"
  586. title="删除这个工作区?"
  587. size="sm"
  588. (openChange)="$event || removingWorkspace.set(null)"
  589. >
  590. <p class="rm-note">
  591. 将删除工作区「{{ removingWorkspace()?.name }}」。
  592. @if (removingWorkspace(); as rw) {
  593. @if (sessionCountIn(rw.id) > 0) {
  594. 其中的 <strong>{{ sessionCountIn(rw.id) }} 条对话会被移回「日常对话」,不会被删除</strong>。
  595. } @else {
  596. 这个工作区里没有对话。
  597. }
  598. }
  599. </p>
  600. <div class="rm-acts">
  601. <ui-button variant="ghost" (click)="removingWorkspace.set(null)">取消</ui-button>
  602. <ui-button variant="danger" (click)="doDeleteWorkspace()">删除工作区</ui-button>
  603. </div>
  604. </ui-modal>
  605. <!-- 归档确认(2026-11-01 用户要求:归档也要二次提示) -->
  606. <ui-modal
  607. [open]="archiving() !== null"
  608. title="归档这条对话?"
  609. size="sm"
  610. (openChange)="$event || archiving.set(null)"
  611. >
  612. <p class="rm-note">
  613. 「{{ archiving()?.title }}」会移到「已归档」里,随时可以取消归档恢复。
  614. </p>
  615. <div class="rm-acts">
  616. <ui-button variant="ghost" (click)="archiving.set(null)">取消</ui-button>
  617. <ui-button variant="primary" (click)="doArchive()">归档</ui-button>
  618. </div>
  619. </ui-modal>
  620. <!--
  621. 删除确认(2026-11-01 补上删除入口)。
  622. 删除类操作一律走居中 ui-modal 二次确认,不用 popconfirm(规范 18 §三-13)。
  623. 文案必须说清「消息不会删」——否则用户会以为内容丢了。
  624. -->
  625. <ui-modal
  626. [open]="removing() !== null"
  627. title="删除这条对话?"
  628. size="sm"
  629. (openChange)="$event || removing.set(null)"
  630. >
  631. <p class="rm-note">
  632. 将从列表删除「{{ removing()?.title }}」,删除后<strong>不可恢复</strong>。
  633. </p>
  634. <div class="rm-acts">
  635. <ui-button variant="ghost" (click)="removing.set(null)">取消</ui-button>
  636. <ui-button variant="danger" (click)="doRemove()">删除</ui-button>
  637. </div>
  638. </ui-modal>
  639. <!-- 会话行模板(工作区与日常共用;owner=null 表示日常) -->
  640. <ng-template #row let-s let-owner="owner">
  641. <div
  642. class="sess"
  643. [class.active]="activeId() === s.id"
  644. [class.archived]="s.archived"
  645. role="button"
  646. tabindex="0"
  647. (click)="openSession(s)"
  648. (keydown.enter)="openSession(s)"
  649. >
  650. <!--
  651. 会话行(2026-10-30 对齐参考图):
  652. · 每行有独立的浅色底 + 圆角,行与行之间留缝
  653. · 标题超长时**左右渐隐**(不是硬截断成省略号)
  654. · 时间靠右、弱化;悬停时切换为操作按钮
  655. · 标题溢出时鼠标移入向左滚动看全文(appHoverScroll)
  656. -->
  657. <span class="sess-title-wrap">
  658. <span class="sess-title" appHoverScroll [title]="s.title">{{ s.title }}</span>
  659. <!--
  660. 容器名(2026-11-04):明确「在跟哪个数字生命对话」。
  661. 群聊没有单一容器,名字串由群标题表达,这里换成成员数(2026-11-06)。
  662. -->
  663. <span class="sess-agent" [title]="s.kind === 'group' ? groupSubline(s) : agentNameOf(s)">
  664. <!--
  665. 容器状态点(2026-11-08 用户要求):
  666. 与移动端会话列表、新建弹窗同一口径 resolveAgentState ——
  667. 判心跳、防上报器自指。原先左栏完全不显示状态(toneOf 是死代码)。
  668. 群聊不挂单容器,不显示点。
  669. -->
  670. @if (s.kind !== 'group') {
  671. <i
  672. class="sess-dot"
  673. [attr.data-tone]="agentStateOf(s).tone"
  674. [attr.title]="agentStateOf(s).text"
  675. [attr.aria-label]="agentStateOf(s).text"
  676. aria-hidden="true"
  677. ></i>
  678. }
  679. {{ s.kind === 'group' ? groupSubline(s) : agentNameOf(s) }}
  680. </span>
  681. </span>
  682. <!-- 左侧列表不显示时间(2026-10-31 澄清:时间指聊天区消息):操作图标常驻 -->
  683. <span class="sess-acts">
  684. <!--
  685. 移动到工作区(2026-10-30):会话一旦建在日常对话里,
  686. 没有这个入口就永远归不进工作区 —— 工作区会变成"只能新建时装进去"。
  687. -->
  688. @if (workspaces().length > 0) {
  689. <ui-tooltip content="移动到工作区">
  690. <ui-button
  691. variant="ghost"
  692. shape="circle"
  693. size="sm"
  694. icon="fa-solid fa-folder-tree"
  695. [ariaLabel]="'移动 ' + s.title + ' 到工作区'"
  696. (click)="openMoveMenu(s, $event)"
  697. />
  698. </ui-tooltip>
  699. }
  700. <ui-tooltip [content]="s.archived ? '取消归档' : '归档'">
  701. <ui-button
  702. variant="ghost"
  703. shape="circle"
  704. size="sm"
  705. [icon]="s.archived ? 'fa-solid fa-box-open' : 'fa-solid fa-box-archive'"
  706. [ariaLabel]="s.archived ? '取消归档 ' + s.title : '归档 ' + s.title"
  707. (click)="toggleArchive(s, $event)"
  708. />
  709. </ui-tooltip>
  710. <!--
  711. 删除(2026-11-01 用户反馈「左侧只有归档没有删除」补上)。
  712. 删除 = 移除会话索引,云端记录物理删除 + 留墓碑;Matrix 里的消息不动。
  713. 必须先弹确认 —— 删除不可恢复。
  714. -->
  715. <ui-tooltip content="删除">
  716. <ui-button
  717. variant="ghost"
  718. shape="circle"
  719. size="sm"
  720. icon="fa-solid fa-trash-can"
  721. [ariaLabel]="'删除 ' + s.title"
  722. (click)="askRemove(s, $event)"
  723. />
  724. </ui-tooltip>
  725. </span>
  726. </div>
  727. </ng-template>
  728. `,
  729. styles: [
  730. `
  731. :host {
  732. display: block;
  733. height: 100%;
  734. min-height: 0;
  735. overflow: hidden;
  736. }
  737. .tree {
  738. display: flex;
  739. flex-direction: column;
  740. height: 100%;
  741. min-height: 0;
  742. /*
  743. * 禁止选中/复制(2026-10-30 用户要求):
  744. * 左栏是导航区,误拖选会产生"文字被刷蓝"的噪音,看起来像界面出错。
  745. * 输入框需要保留可选中,所以单独在 .inline-input / ui-input 上放行。
  746. */
  747. user-select: none;
  748. -webkit-user-select: none;
  749. }
  750. /* 输入类元素必须能选中文本,否则无法编辑/复制自己输入的内容 */
  751. .tree input,
  752. .tree textarea {
  753. user-select: text;
  754. -webkit-user-select: text;
  755. /*
  756. * 底色用 canvas 与主区一致(2026-10-29 对齐 ChatGPT 侧栏观感):
  757. * 原来整块 surface 白底 + 右边框,像一张贴在左边的卡片;
  758. * 改成同底色、只留一道极淡的边,左栏与对话流更像同一块界面。
  759. */
  760. background: var(--canvas);
  761. border-right: 1px solid var(--edge);
  762. }
  763. /*
  764. * 搜索框(2026-10-29 对齐 ChatGPT 侧栏):
  765. * ui-input 自带明显边框,在侧栏里视觉重量过大 —— 用 :host 内的穿透无法做,
  766. * 改为外层容器收紧内边距,让输入框自己占满,减少两侧空白带来的"卡在中间"感。
  767. */
  768. /* ---- 品牌区 ---- */
  769. /*
  770. * 品牌行(2026-10-30 按参考图调整):
  771. * · 去掉品牌图标,只留文字
  772. * · 文字左缘与下方菜单**图标列**对齐(不是与图标左边距对齐)
  773. * · 行高 38 → 48,字号 16 → 20,整体更有"标题"分量
  774. */
  775. .brand-row {
  776. display: flex;
  777. align-items: center;
  778. gap: var(--sp-1);
  779. /* 左内边距与 .routes 一致,文字起点再对齐到图标列(见 .brand-word) */
  780. padding: 0 var(--sp-2) 0 var(--sp-2);
  781. }
  782. .brand {
  783. display: flex;
  784. flex: 1;
  785. min-width: 0;
  786. align-items: center;
  787. min-height: 48px;
  788. /* 图标列宽 16 + 图标与文字间距 12 → 文字起点与菜单项文字列对齐 */
  789. padding-left: var(--sp-3);
  790. flex-shrink: 0;
  791. text-decoration: none;
  792. color: var(--ink);
  793. }
  794. .brand-mark {
  795. display: inline-grid;
  796. place-items: center;
  797. /* 与折叠按钮同尺寸,两者在品牌行里水平对齐 */
  798. width: 30px;
  799. height: 30px;
  800. border-radius: var(--r-sm);
  801. background: var(--nav-surface);
  802. color: var(--nav-brand);
  803. font-size: var(--fs-12);
  804. }
  805. .brand-word {
  806. font-size: var(--fs-20);
  807. font-weight: 700;
  808. letter-spacing: -0.5px;
  809. color: var(--ink);
  810. }
  811. /*
  812. * 折叠按钮(2026-10-30 按用户要求调整):
  813. * · **常驻显示**(原来悬停才出现,用户找不到入口)
  814. * · 尺寸放大到与品牌图标同级(30px),垂直居中对齐
  815. */
  816. .collapse-btn {
  817. display: grid;
  818. place-items: center;
  819. width: 30px;
  820. height: 30px;
  821. flex-shrink: 0;
  822. border: 0;
  823. border-radius: var(--r-sm);
  824. background: transparent;
  825. color: var(--muted);
  826. font-size: var(--fs-14);
  827. cursor: pointer;
  828. transition: background-color var(--motion-fast) var(--ease-standard),
  829. color var(--motion-fast) var(--ease-standard);
  830. }
  831. .collapse-btn:hover {
  832. background: var(--surface-hover);
  833. color: var(--ink);
  834. }
  835. /* ---- 一级路由 ---- */
  836. .routes {
  837. display: grid;
  838. gap: 0;
  839. padding: 0 var(--sp-2) var(--sp-1);
  840. flex-shrink: 0;
  841. }
  842. .route {
  843. display: flex;
  844. align-items: center;
  845. gap: var(--sp-3);
  846. /*
  847. * 行高与字号(2026-10-30 按参考图放大):30→38、13→14。
  848. * 参考图的导航行明显更松、字更实,30px 在高分屏上偏挤。
  849. */
  850. min-height: 38px;
  851. padding: 0 var(--sp-3);
  852. border-radius: var(--r-md);
  853. color: var(--ink-2);
  854. font-size: var(--fs-14);
  855. text-decoration: none;
  856. transition: background-color var(--motion-fast) var(--ease-standard);
  857. }
  858. .route:hover {
  859. background: var(--surface-hover);
  860. color: var(--ink);
  861. }
  862. .route.active {
  863. background: var(--surface-hover);
  864. color: var(--ink);
  865. font-weight: 600;
  866. }
  867. .route > i {
  868. /* 图标列固定 18px:品牌文字的左缘就是靠它算出来的对齐基准 */
  869. width: 18px;
  870. flex-shrink: 0;
  871. text-align: center;
  872. color: var(--muted);
  873. font-size: var(--fs-14);
  874. }
  875. .route.active > i {
  876. color: var(--primary);
  877. }
  878. .route-label {
  879. flex: 1;
  880. min-width: 0;
  881. overflow: hidden;
  882. text-overflow: ellipsis;
  883. white-space: nowrap;
  884. }
  885. /*
  886. * 「更多」按钮(2026-10-30 修悬停底色不满行):
  887. * 它是 <button>,默认盒模型下宽度按内容算 —— 即使写了 width:100%,
  888. * 因为没有 padding/border 归零的显式声明,视觉上也比 <a> 行窄一截,
  889. * 悬停底色右侧会缺一块(用户实测)。
  890. * 这里把盒模型与 <a> 行完全对齐:100% 宽 + 同一套 padding + box-sizing。
  891. */
  892. .route-more {
  893. width: 100%;
  894. box-sizing: border-box;
  895. border: 0;
  896. background: transparent;
  897. cursor: pointer;
  898. font: inherit;
  899. text-align: left;
  900. /* 与 .route 的左右内边距一致,保证文字起点对齐 */
  901. padding: 0 var(--sp-3);
  902. }
  903. .more-caret {
  904. font-size: var(--fs-11);
  905. color: var(--faint);
  906. transition: transform var(--motion-fast) var(--ease-standard);
  907. }
  908. .route-more[aria-expanded='true'] .more-caret {
  909. transform: rotate(180deg);
  910. }
  911. /* 「更多」浮层:fixed 定位,避免被侧栏 overflow 裁切 */
  912. .more-menu {
  913. position: fixed;
  914. z-index: calc(var(--z-popover) + 1);
  915. min-width: 176px;
  916. padding: var(--sp-1);
  917. border: 1px solid var(--edge);
  918. border-radius: var(--r-md);
  919. background: var(--surface);
  920. box-shadow: var(--shadow-pop);
  921. /* 位置由 JS 按「更多」按钮的实时位置算出(fixed 定位,不被侧栏裁切) */
  922. }
  923. .more-line {
  924. margin: var(--sp-1) 0;
  925. border: 0;
  926. border-top: 1px solid var(--edge);
  927. }
  928. /* 动作型路由项(新建对话)用 button 渲染,需要复刻 <a> 的样式 */
  929. .route-action {
  930. width: 100%;
  931. border: 0;
  932. background: transparent;
  933. font: inherit;
  934. text-align: left;
  935. cursor: pointer;
  936. }
  937. .more-item {
  938. display: flex;
  939. align-items: center;
  940. gap: var(--sp-3);
  941. /* 与一级导航 .route 对齐:38px 行高 + 显式内边距,悬停高亮块与文字行一样宽高 */
  942. width: 100%;
  943. min-height: 38px;
  944. padding: 0 var(--sp-3);
  945. border: 0;
  946. border-radius: var(--r-md);
  947. background: transparent;
  948. color: var(--ink);
  949. font: inherit;
  950. font-size: var(--fs-14);
  951. text-align: left;
  952. text-decoration: none;
  953. cursor: pointer;
  954. }
  955. .more-item:hover {
  956. background: var(--surface-hover);
  957. }
  958. .more-item:focus-visible {
  959. outline: 2px solid var(--primary);
  960. outline-offset: -2px;
  961. }
  962. .more-item.active {
  963. background: var(--surface-active);
  964. font-weight: 600;
  965. }
  966. .more-item > i {
  967. width: 16px;
  968. text-align: center;
  969. color: var(--muted);
  970. }
  971. .more-item.active > i {
  972. color: var(--primary);
  973. }
  974. .route-badge {
  975. min-width: 18px;
  976. padding: 0 5px;
  977. border-radius: var(--r-tag);
  978. background: var(--danger);
  979. color: var(--on-danger);
  980. font-size: var(--fs-11);
  981. line-height: 18px;
  982. text-align: center;
  983. }
  984. /*
  985. * 会话区(2026-10-30 重做):
  986. *
  987. * 🔴 原来整块 tree-scroll 可滚 + 每个分组再限高 34vh ——
  988. * 结果工作区内容一多,整块被撑满,「日常对话」被挤到需要滚动才能看见,
  989. * 用户以为它消失了(实测:seed 29 条会话后日常对话分组直接不可见)。
  990. *
  991. * 现在的规则:
  992. * · 外层**不滚**(flex 列,min-height: 0)
  993. * · 每个分组按内容分配高度,**各自内部滚动**(flex: 1 1 auto + min-height: 0)
  994. * · 分组标题固定,永远看得到「工作区 / 日常对话」两个名字
  995. */
  996. .tree-scroll {
  997. display: flex;
  998. flex-direction: column;
  999. gap: var(--sp-3);
  1000. flex: 1;
  1001. min-height: 0;
  1002. overflow: hidden;
  1003. padding: 0 var(--sp-2) var(--sp-3);
  1004. }
  1005. /*
  1006. * 分组高度分配(2026-10-30 第二次修正):
  1007. * 第一版用 0 1 auto —— flex 子项按内容撑高,工作区一多就把整块占满,
  1008. * 下面的「日常对话」直接被顶出可视区(实测 4 个工作区 29 条会话时复现)。
  1009. *
  1010. * 现在:
  1011. * · 工作区 → 最多 45% 高度,超出自己滚
  1012. * · 日常对话 → 吃掉剩余高度(flex: 1),始终可见
  1013. */
  1014. .sect {
  1015. display: flex;
  1016. flex-direction: column;
  1017. min-height: 0;
  1018. flex: 0 1 auto;
  1019. /*
  1020. * 🔴 用绝对值而不是 45%:tree-scroll 的父链上有多个 min-height:0 的 flex 容器,
  1021. * 百分比 max-height 在这种嵌套里解析不稳定(实测 4 个工作区时该值完全没生效,
  1022. * 工作区仍然撑到 483px,日常对话被压成 0 高)。
  1023. */
  1024. /*
  1025. * 工作区最多 26vh(2026-10-30 再次压缩:32 → 26)。
  1026. * 左侧区域要同时容纳品牌/路由/新建/工作区/日常/用户卡,
  1027. * 工作区占太多会把日常对话压得只剩两行。
  1028. */
  1029. max-height: 26vh;
  1030. }
  1031. .sect.grow {
  1032. flex: 1 1 0;
  1033. max-height: none;
  1034. /* 至少给到三行多,保证"这里有内容"是真的看得见 */
  1035. min-height: 140px;
  1036. }
  1037. /*
  1038. * 群聊分组限高(2026-11-06):常驻显示后它和工作区一样按内容撑高,
  1039. * 群一多就会把「日常对话」压成 0 高 —— 日常对话是主场景,必须始终可见。
  1040. */
  1041. .sect--group {
  1042. max-height: 26vh;
  1043. }
  1044. .sect-head {
  1045. flex-shrink: 0;
  1046. }
  1047. /*
  1048. * 分组列表:内部滚动。
  1049. * 底部加一道渐隐遮罩 —— 列表被截断时,用户需要"下面还有"的视觉暗示,
  1050. * 否则会以为工作区里就这几条(实测 feedback:以为内容丢了)。
  1051. */
  1052. .list-limit {
  1053. min-height: 0;
  1054. overflow-y: auto;
  1055. overflow-x: hidden;
  1056. overscroll-behavior: contain;
  1057. }
  1058. /*
  1059. * 只在「确实还有下方内容」时才加渐隐遮罩(由 JS 维护 more-below 类)。
  1060. * 常驻遮罩会把最后一条莫名淡化,看着像被截断。
  1061. */
  1062. .list-limit.more-below {
  1063. mask-image: linear-gradient(to bottom, currentColor calc(100% - 20px), transparent 100%);
  1064. }
  1065. .sect.grow .list-limit {
  1066. flex: 1 1 auto;
  1067. }
  1068. /* 分组标题做成可点击的整行(日常对话折叠用) */
  1069. .sect-toggle {
  1070. display: flex;
  1071. align-items: center;
  1072. gap: var(--sp-2);
  1073. flex: 1;
  1074. min-width: 0;
  1075. min-height: 30px;
  1076. padding: 0;
  1077. border: 0;
  1078. background: transparent;
  1079. color: inherit;
  1080. cursor: pointer;
  1081. text-align: left;
  1082. }
  1083. .sect-toggle:hover .sect-name {
  1084. color: var(--ink);
  1085. }
  1086. /*
  1087. * 分组标题的「前置对齐槽」(2026-11-06)
  1088. *
  1089. * 三个分组标题此前各写各的:工作区没有前置元素、群聊是 fa-users 图标、
  1090. * 日常对话是折叠箭头 —— 三者宽度不同,**文字起点参差不齐**(用户实测指出)。
  1091. * 统一给一个固定宽度的槽位,图标/箭头居中放在里面,文字就自然对齐了。
  1092. * 三个分组各配一个语义图标:工作区 fa-layer-group / 群聊 fa-users /
  1093. * 日常对话保留折叠箭头(它是唯一可折叠的分组)。
  1094. */
  1095. .sect-lead {
  1096. display: inline-flex;
  1097. align-items: center;
  1098. justify-content: center;
  1099. width: 14px;
  1100. flex-shrink: 0;
  1101. font-size: var(--fs-11);
  1102. color: var(--faint);
  1103. }
  1104. /* 当前筛选提示条 */
  1105. .search-chip {
  1106. display: flex;
  1107. align-items: center;
  1108. gap: var(--sp-2);
  1109. margin: 0 var(--sp-2) var(--sp-2);
  1110. padding: var(--sp-1) var(--sp-2);
  1111. border-radius: var(--r-sm);
  1112. background: var(--primary-subtle);
  1113. color: var(--primary);
  1114. font-size: var(--fs-12);
  1115. }
  1116. .search-chip .ellipsis {
  1117. flex: 1;
  1118. min-width: 0;
  1119. }
  1120. .chip-x {
  1121. display: grid;
  1122. place-items: center;
  1123. width: 20px;
  1124. height: 20px;
  1125. flex-shrink: 0;
  1126. border: 0;
  1127. border-radius: var(--r-sm);
  1128. background: transparent;
  1129. color: inherit;
  1130. cursor: pointer;
  1131. }
  1132. .chip-x:hover {
  1133. background: var(--primary);
  1134. color: var(--primary-content);
  1135. }
  1136. .sect + .sect {
  1137. margin-top: var(--sp-4);
  1138. }
  1139. .sect-head {
  1140. display: flex;
  1141. align-items: center;
  1142. gap: var(--sp-2);
  1143. min-height: 30px;
  1144. padding: 0 var(--sp-2);
  1145. }
  1146. .sect-name {
  1147. font-size: var(--fs-12);
  1148. font-weight: 500;
  1149. color: var(--muted);
  1150. letter-spacing: 0.2px;
  1151. /* 名字占满剩余空间,「+」被推到最右 */
  1152. flex: 1;
  1153. }
  1154. /* 分组头的「+」**常驻显示**(2026-11-01 用户要求)。 */
  1155. .sect-head ui-button {
  1156. opacity: 1;
  1157. margin-left: auto;
  1158. }
  1159. /*
  1160. * 计数紧跟名字、贴着「+」左侧(2026-11-06 用户要求)。
  1161. *
  1162. * 此前计数紧跟在名字后面(工作区是「工作区 0 +」,0 离名字很远),
  1163. * 群聊的计数更是排在标题文字里,三个分组头的计数位置各不相同。
  1164. * 统一成:名字 ──剩余空间── 计数 +,三个分组一眼对齐。
  1165. */
  1166. .sect-count {
  1167. margin-left: auto;
  1168. font-size: var(--fs-11);
  1169. color: var(--faint);
  1170. font-variant-numeric: tabular-nums;
  1171. }
  1172. /* 名字已有 flex:1 时,计数再用 auto 会把「+」推过头 —— 两者让名字吃空间 */
  1173. .sect-name + .sect-count {
  1174. margin-left: 0;
  1175. }
  1176. .empty-add {
  1177. display: flex;
  1178. align-items: center;
  1179. gap: var(--sp-2);
  1180. width: calc(100% - var(--sp-4));
  1181. margin: 0 var(--sp-2) var(--sp-1);
  1182. min-height: 32px;
  1183. padding: 0 var(--sp-2);
  1184. border: 1px dashed var(--edge-strong);
  1185. border-radius: var(--r-md);
  1186. background: transparent;
  1187. color: var(--muted);
  1188. font-size: var(--fs-12);
  1189. text-align: left;
  1190. cursor: pointer;
  1191. }
  1192. .empty-add:hover {
  1193. border-color: var(--primary);
  1194. color: var(--primary);
  1195. }
  1196. .sect-hint {
  1197. margin: var(--sp-1) 0 var(--sp-2);
  1198. padding: 0 var(--sp-3);
  1199. font-size: var(--fs-12);
  1200. color: var(--muted);
  1201. line-height: 1.6;
  1202. }
  1203. .sect-hint.inset {
  1204. padding-left: var(--sp-6);
  1205. }
  1206. .orphan-hint {
  1207. padding-top: var(--sp-2);
  1208. border-top: 1px solid var(--edge);
  1209. }
  1210. /*
  1211. 会话目录加载失败(2026-11-01):这是**错误态**,不是空态。
  1212. 用 danger 语义色边框区分,并提供重试入口 —— 不允许静默展示旧缓存。
  1213. */
  1214. .sect-error {
  1215. margin: var(--sp-1) 0 var(--sp-2);
  1216. padding: var(--sp-3);
  1217. border: 1px solid var(--danger);
  1218. border-radius: var(--r-md);
  1219. background: var(--danger-subtle);
  1220. font-size: var(--fs-12);
  1221. line-height: 1.6;
  1222. color: var(--ink);
  1223. }
  1224. .sect-error p {
  1225. margin: 0;
  1226. }
  1227. .sect-error-detail {
  1228. margin-top: var(--sp-1) !important;
  1229. color: var(--muted);
  1230. word-break: break-all;
  1231. }
  1232. .sect-error .retry {
  1233. display: inline-flex;
  1234. align-items: center;
  1235. gap: var(--sp-1);
  1236. min-height: var(--touch-min);
  1237. margin-top: var(--sp-1);
  1238. padding: 0 var(--sp-2);
  1239. border: none;
  1240. border-radius: var(--r-sm);
  1241. background: transparent;
  1242. color: var(--primary);
  1243. font-size: var(--fs-12);
  1244. cursor: pointer;
  1245. }
  1246. .sect-error .retry:hover {
  1247. background: var(--surface-hover);
  1248. }
  1249. .sect-error .retry:focus-visible {
  1250. outline: 2px solid var(--primary);
  1251. outline-offset: 2px;
  1252. }
  1253. /* 删除确认框(当前元件在 host 外,样式要挂在模板根上) */
  1254. .rm-note {
  1255. margin: 0 0 var(--sp-4);
  1256. font-size: var(--fs-13);
  1257. line-height: 1.7;
  1258. color: var(--ink-2);
  1259. }
  1260. .rm-acts {
  1261. display: flex;
  1262. justify-content: flex-end;
  1263. gap: var(--sp-2);
  1264. }
  1265. .skel {
  1266. display: grid;
  1267. gap: var(--sp-1);
  1268. padding: var(--sp-1) var(--sp-2);
  1269. }
  1270. /* ---- 工作区行 ---- */
  1271. .ws-head {
  1272. display: flex;
  1273. align-items: center;
  1274. gap: var(--sp-2);
  1275. min-height: 32px;
  1276. padding: 0 var(--sp-2);
  1277. border-radius: var(--r-sm);
  1278. color: var(--ink);
  1279. font-size: var(--fs-13);
  1280. cursor: pointer;
  1281. }
  1282. .ws-head:hover {
  1283. background: var(--surface-hover);
  1284. }
  1285. .caret {
  1286. width: 10px;
  1287. font-size: var(--fs-11);
  1288. color: var(--faint);
  1289. flex-shrink: 0;
  1290. }
  1291. /* 分组标题里的箭头由 .sect-lead 统一控宽,避免双重约束 */
  1292. .sect-lead .caret {
  1293. width: auto;
  1294. }
  1295. .ws-name {
  1296. flex: 1;
  1297. min-width: 0;
  1298. }
  1299. /*
  1300. * 工作区操作图标**常驻显示**(2026-11-01 用户要求)。
  1301. *
  1302. * 原先是 display:none + 悬停/聚焦才出现 —— 用户不知道文件夹上还有这些操作,
  1303. * 只能靠"碰运气"把鼠标移上去。侧栏本来就窄,三颗小图标常驻不会造成拥挤,
  1304. * 换来的是「看得见就能点」。
  1305. */
  1306. .ws-acts {
  1307. display: flex;
  1308. gap: 0;
  1309. flex-shrink: 0;
  1310. }
  1311. .ws-body {
  1312. padding-left: var(--sp-4);
  1313. }
  1314. .ws-edit {
  1315. padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-6);
  1316. }
  1317. .inline-input {
  1318. width: 100%;
  1319. min-height: 28px;
  1320. padding: 0 var(--sp-2);
  1321. border: 1px solid var(--primary);
  1322. border-radius: var(--r-sm);
  1323. background: var(--surface);
  1324. color: var(--ink);
  1325. font-size: var(--fs-13);
  1326. /* 不用 outline:none —— 焦点环由全局 :focus-visible 提供,移除会违反可访问性底线 */
  1327. }
  1328. .inline-input.grow {
  1329. flex: 1;
  1330. min-width: 0;
  1331. }
  1332. /* ---- 会话行 ---- */
  1333. .sess {
  1334. display: flex;
  1335. align-items: center;
  1336. gap: var(--sp-2);
  1337. /* 行高 36 + 上下留缝,形成参考图那种"一条条卡片"的观感 */
  1338. min-height: 36px;
  1339. margin-bottom: 2px;
  1340. padding: 0 var(--sp-3);
  1341. border-radius: var(--r-md);
  1342. /*
  1343. * 默认**无背景**(2026-10-30 用户反馈):与侧栏底色一致,只在悬停/选中时给背景。
  1344. * 之前每行都铺 surface 白底,列表看起来像一片卡片墙,反而分不清层级。
  1345. */
  1346. background: transparent;
  1347. color: var(--ink-2);
  1348. font-size: var(--fs-13);
  1349. cursor: pointer;
  1350. transition: background-color var(--motion-fast) var(--ease-standard);
  1351. }
  1352. /* 标题容器:负责裁切与两端渐隐 */
  1353. .sess-agent {
  1354. display: block;
  1355. overflow: hidden;
  1356. text-overflow: ellipsis;
  1357. white-space: nowrap;
  1358. color: var(--muted);
  1359. font-size: var(--fs-11);
  1360. line-height: 1.4;
  1361. }
  1362. /*
  1363. * 容器状态点(2026-11-08):6px,跟在容器名前面。
  1364. * 颜色只是加强 —— 文案在 title / aria-label 里,悬停可读。
  1365. */
  1366. .sess-dot {
  1367. display: inline-block;
  1368. width: 6px;
  1369. height: 6px;
  1370. margin-right: 4px;
  1371. border-radius: 50%;
  1372. vertical-align: middle;
  1373. background: var(--faint);
  1374. }
  1375. .sess-dot[data-tone='success'] {
  1376. background: var(--success);
  1377. }
  1378. .sess-dot[data-tone='warning'] {
  1379. background: var(--warning);
  1380. }
  1381. .sess-dot[data-tone='danger'] {
  1382. background: var(--danger);
  1383. }
  1384. .sess-dot[data-tone='info'] {
  1385. background: var(--primary);
  1386. }
  1387. .sess-title-wrap {
  1388. flex: 1;
  1389. min-width: 0;
  1390. overflow: hidden;
  1391. /* 右侧渐隐:提示"后面还有字"比硬省略号更柔和 */
  1392. mask-image: linear-gradient(to right, currentColor 88%, transparent 100%);
  1393. }
  1394. .sess-title {
  1395. display: inline-block;
  1396. white-space: nowrap;
  1397. transition-property: transform;
  1398. transition-timing-function: linear;
  1399. }
  1400. .sess:hover {
  1401. background: var(--surface-hover);
  1402. color: var(--ink);
  1403. }
  1404. /*
  1405. * 选中态(2026-10-29):从「整块深色底」改为**浅底 + 左侧短线 + 加粗**。
  1406. * 原来一整块 surface-active 蓝灰,在浅色主题里像被高亮标记的文本,
  1407. * 一屏里最抢眼的不是内容而是选中行。
  1408. */
  1409. .sess.active {
  1410. background: var(--surface-hover);
  1411. color: var(--ink);
  1412. font-weight: 600;
  1413. }
  1414. .sess-ico {
  1415. flex-shrink: 0;
  1416. font-size: var(--fs-12);
  1417. color: var(--faint);
  1418. }
  1419. .sess.active .sess-ico {
  1420. color: var(--primary);
  1421. }
  1422. .sess.archived {
  1423. color: var(--muted);
  1424. }
  1425. .sess-title {
  1426. flex: 1;
  1427. min-width: 0;
  1428. }
  1429. /*
  1430. * 会话行(2026-10-31):
  1431. * · 操作图标常驻,默认低透明度,悬停/聚焦/选中恢复全亮
  1432. * · 不显示时间 —— 用户澄清「时间」指聊天区每条消息,不是左侧列表
  1433. */
  1434. .sess-time {
  1435. display: none;
  1436. }
  1437. .sess-acts {
  1438. display: flex;
  1439. flex-shrink: 0;
  1440. opacity: 0.55;
  1441. transition: opacity var(--motion-fast) var(--ease-standard);
  1442. }
  1443. .sess:hover .sess-acts,
  1444. .sess:focus-within .sess-acts,
  1445. .sess.active .sess-acts {
  1446. opacity: 1;
  1447. }
  1448. .arch-toggle {
  1449. display: flex;
  1450. align-items: center;
  1451. gap: var(--sp-2);
  1452. width: 100%;
  1453. min-height: 28px;
  1454. padding: 0 var(--sp-2);
  1455. border: 0;
  1456. border-radius: var(--r-sm);
  1457. background: transparent;
  1458. color: var(--muted);
  1459. font-size: var(--fs-12);
  1460. text-align: left;
  1461. cursor: pointer;
  1462. }
  1463. .arch-toggle:hover {
  1464. background: var(--surface-hover);
  1465. color: var(--ink-2);
  1466. }
  1467. .arch-count {
  1468. font-size: var(--fs-11);
  1469. }
  1470. /* ---- 底部用户卡 ---- */
  1471. /*
  1472. * 底部账号区(2026-10-30 对照 WorkBuddy 参考图重做):
  1473. * 原来是「白色卡片 + 上边框」贴在灰色侧栏底部,像一块补丁。
  1474. * 参考图的账号区是**同底色、无边框**的一行,只在悬停时给浅底 —— 视觉上属于侧栏的一部分。
  1475. */
  1476. .user-card {
  1477. position: relative;
  1478. flex-shrink: 0;
  1479. padding: var(--sp-2);
  1480. background: transparent;
  1481. }
  1482. .user-main {
  1483. display: flex;
  1484. align-items: center;
  1485. gap: var(--sp-3);
  1486. width: 100%;
  1487. min-height: var(--touch-min);
  1488. padding: var(--sp-2) var(--sp-2);
  1489. border: 0;
  1490. border-radius: var(--r-md);
  1491. background: transparent;
  1492. color: var(--ink);
  1493. text-align: left;
  1494. cursor: pointer;
  1495. }
  1496. /* 头像本体 32px,外层按钮必须留出余量,否则被边框指标裁掉 */
  1497. .user-main ui-avatar {
  1498. flex-shrink: 0;
  1499. display: block;
  1500. }
  1501. /* 悬停时才出现的「⋯」,常驻会让整行显得拥挤 */
  1502. .user-main > .dots {
  1503. color: var(--muted);
  1504. opacity: 0;
  1505. transition: opacity var(--motion-fast) var(--ease-standard);
  1506. }
  1507. /*
  1508. * 用户卡片:悬停反馈 + 箭头方向(2026-11-01 用户要求)。
  1509. *
  1510. * · 默认箭头**朝上**(▲)=「点我向上展开菜单」
  1511. * · 悬停 / 菜单已展开 → 箭头**朝下**(▼)
  1512. * · 整行背景随悬停变浅,明确「这一整条都是可点的」
  1513. */
  1514. .user-main:hover {
  1515. background: var(--surface-hover);
  1516. }
  1517. .user-main:active {
  1518. background: var(--surface-active);
  1519. }
  1520. .user-main:hover > .dots,
  1521. .user-main:focus-visible > .dots {
  1522. opacity: 1;
  1523. }
  1524. /*
  1525. * 悬停就把箭头翻过来(原来是 .rot 只在菜单展开时加)。
  1526. * 用 [aria-expanded='true'] 覆盖:菜单已展开时保持朝下,
  1527. * 否则鼠标移开又翻回朝上,与「菜单开着」的状态矛盾。
  1528. */
  1529. .user-main:hover > .dots,
  1530. .user-main:focus-visible > .dots,
  1531. .user-main[aria-expanded='true'] > .dots {
  1532. transform: rotate(180deg);
  1533. }
  1534. .dots {
  1535. transition: opacity var(--motion-fast) var(--ease-standard),
  1536. transform var(--motion-fast) var(--ease-standard);
  1537. }
  1538. /* 头像 + 在线状态点 */
  1539. .user-avatar {
  1540. position: relative;
  1541. flex-shrink: 0;
  1542. display: block;
  1543. }
  1544. .user-dot {
  1545. position: absolute;
  1546. right: -1px;
  1547. bottom: -1px;
  1548. width: 9px;
  1549. height: 9px;
  1550. border-radius: 50%;
  1551. background: var(--success);
  1552. box-shadow: 0 0 0 2px var(--canvas);
  1553. }
  1554. .user-dot.off {
  1555. background: var(--faint);
  1556. }
  1557. .user-role {
  1558. font-size: var(--fs-11);
  1559. color: var(--muted);
  1560. }
  1561. .user-main:hover {
  1562. background: var(--surface-hover);
  1563. }
  1564. .user-main:active {
  1565. background: var(--surface-active);
  1566. }
  1567. .user-text {
  1568. display: grid;
  1569. flex: 1;
  1570. min-width: 0;
  1571. line-height: 1.3;
  1572. }
  1573. .user-name {
  1574. font-size: var(--fs-13);
  1575. font-weight: 600;
  1576. color: var(--ink);
  1577. }
  1578. .user-sub {
  1579. font-size: var(--fs-11);
  1580. color: var(--muted);
  1581. }
  1582. .menu-backdrop {
  1583. position: fixed;
  1584. inset: 0;
  1585. z-index: var(--z-popover);
  1586. }
  1587. .user-menu {
  1588. position: absolute;
  1589. left: var(--sp-2);
  1590. right: var(--sp-2);
  1591. bottom: calc(100% + var(--sp-1));
  1592. z-index: calc(var(--z-popover) + 1);
  1593. max-height: 60vh;
  1594. overflow-y: auto;
  1595. padding: var(--sp-1);
  1596. border: 1px solid var(--edge);
  1597. border-radius: var(--r-md);
  1598. background: var(--surface);
  1599. box-shadow: var(--shadow-pop);
  1600. }
  1601. .move-backdrop {
  1602. position: fixed;
  1603. inset: 0;
  1604. z-index: var(--z-popover);
  1605. }
  1606. .move-menu {
  1607. position: fixed;
  1608. z-index: calc(var(--z-popover) + 1);
  1609. min-width: 180px;
  1610. max-height: 60vh;
  1611. overflow-y: auto;
  1612. padding: var(--sp-1);
  1613. border: 1px solid var(--edge);
  1614. border-radius: var(--r-md);
  1615. background: var(--surface);
  1616. box-shadow: var(--shadow-pop);
  1617. }
  1618. .menu-label {
  1619. margin: var(--sp-1) 0 0;
  1620. padding: var(--sp-1) var(--sp-2);
  1621. font-size: var(--fs-11);
  1622. color: var(--faint);
  1623. }
  1624. /* 带控件的菜单行(开关 / 色块):不可点击,控件自己负责交互 */
  1625. /*
  1626. * 深色模式行(2026-10-30 调整):
  1627. * 原来开关比文字还抢眼(ui-switch md 尺寸 + 行高 36)。
  1628. * 现在行高 34、开关用 sm,让「深色模式」这几个字是主体、开关是配角。
  1629. */
  1630. .menu-row {
  1631. display: flex;
  1632. align-items: center;
  1633. gap: var(--sp-2);
  1634. min-height: 34px;
  1635. padding: 0 var(--sp-2);
  1636. color: var(--ink);
  1637. font-size: var(--fs-13);
  1638. }
  1639. .menu-row > i {
  1640. width: 14px;
  1641. text-align: center;
  1642. color: var(--muted);
  1643. }
  1644. .menu-row-text {
  1645. flex: 1;
  1646. min-width: 0;
  1647. }
  1648. .menu-item {
  1649. display: flex;
  1650. align-items: center;
  1651. gap: var(--sp-2);
  1652. width: 100%;
  1653. min-height: 32px;
  1654. padding: 0 var(--sp-2);
  1655. border: 0;
  1656. border-radius: var(--r-sm);
  1657. background: transparent;
  1658. color: var(--ink);
  1659. font-size: var(--fs-13);
  1660. text-align: left;
  1661. cursor: pointer;
  1662. }
  1663. .menu-item:hover {
  1664. background: var(--surface-hover);
  1665. }
  1666. .menu-item[aria-checked='true'] {
  1667. color: var(--primary);
  1668. font-weight: 600;
  1669. }
  1670. /* ---- 配色项:色块 + 名称 ---- */
  1671. .swatch-item {
  1672. display: flex;
  1673. align-items: center;
  1674. gap: var(--sp-3);
  1675. width: 100%;
  1676. min-height: 34px;
  1677. padding: 0 var(--sp-2);
  1678. border: 0;
  1679. border-radius: var(--r-sm);
  1680. background: transparent;
  1681. color: var(--ink);
  1682. font-size: var(--fs-13);
  1683. text-align: left;
  1684. cursor: pointer;
  1685. }
  1686. .swatch-item:hover {
  1687. background: var(--surface-hover);
  1688. }
  1689. .swatch-item[aria-checked='true'] {
  1690. font-weight: 600;
  1691. }
  1692. /*
  1693. * 色块(2026-10-30):**不写任何色值**。
  1694. * data-preview-palette 作用域里,_palette.scss 已经把该配色的整套令牌
  1695. * (--nav-surface / --primary 等)覆盖成那套的真实取值 ——
  1696. * 直接引用它们,色块天然跟随配色定义,配色调整时不用改这里。
  1697. */
  1698. .swatch {
  1699. display: grid;
  1700. place-items: center;
  1701. /* 20×20:与文字行高的视觉重量匹配,22 在小字号旁边显得头重 */
  1702. width: 20px;
  1703. height: 20px;
  1704. flex-shrink: 0;
  1705. border-radius: var(--r-sm);
  1706. border: 1px solid var(--edge);
  1707. background: var(--canvas);
  1708. overflow: hidden;
  1709. }
  1710. .swatch-in {
  1711. display: block;
  1712. width: 100%;
  1713. height: 100%;
  1714. background: linear-gradient(135deg, var(--nav-surface) 50%, var(--primary) 50%);
  1715. }
  1716. .swatch-name {
  1717. flex: 1;
  1718. min-width: 0;
  1719. }
  1720. .swatch-check {
  1721. color: var(--primary);
  1722. font-size: var(--fs-12);
  1723. }
  1724. .menu-item > i {
  1725. width: 14px;
  1726. text-align: center;
  1727. }
  1728. .menu-line {
  1729. margin: var(--sp-1) 0;
  1730. border: 0;
  1731. border-top: 1px solid var(--edge);
  1732. }
  1733. .ellipsis {
  1734. overflow: hidden;
  1735. text-overflow: ellipsis;
  1736. white-space: nowrap;
  1737. }
  1738. `,
  1739. ],
  1740. })
  1741. export class SessionTreeComponent implements OnDestroy {
  1742. private readonly sessionsSvc = inject(ChatSessionService);
  1743. private readonly workspaceSvc = inject(WorkspaceService);
  1744. private readonly navMenu = inject(NavMenuService);
  1745. private readonly chatFont = inject(ChatFontService);
  1746. /** 自定义菜单栏弹窗开关 */
  1747. protected readonly navCustom = inject(NavCustomModalService);
  1748. /** 新建对话弹窗(群聊分组头的「+」直达群聊标签) */
  1749. private readonly newSessionModal = inject(NewSessionModalService);
  1750. private readonly liveSvc = inject(LiveService);
  1751. private readonly unreadSvc = inject(UnreadService);
  1752. private readonly session = inject(SessionService);
  1753. private readonly router = inject(Router);
  1754. private readonly device = inject(DeviceService);
  1755. /** 窗口对象(单测里可替换,避免直接摸全局) */
  1756. private readonly win = window;
  1757. /**
  1758. * 窗口重新获得焦点 → 重拉会话目录(2026-11-09)。
  1759. *
  1760. * 用户诉求:换浏览器 / 换标签页回来必须看到最新数据。
  1761. * 只拉「确实变化了」的东西,不做定时轮询。
  1762. */
  1763. private readonly onWindowFocus = (): void => {
  1764. if (!this.session.isLoggedIn()) return;
  1765. const ids = this.liveSvc
  1766. .agents()
  1767. .filter((a) => a.matrixReady)
  1768. .map((a) => a.id);
  1769. if (ids.length === 0) return;
  1770. void this.sessionsSvc.reloadAll(ids);
  1771. };
  1772. /** 与 chat 页共用同一实例:打开会话走同一套 URL 规则 */
  1773. protected readonly chat = inject(ChatService);
  1774. protected readonly theme = inject(ThemeService);
  1775. protected readonly palette = inject(PaletteService);
  1776. protected readonly palettes = PALETTES;
  1777. protected readonly live = this.liveSvc;
  1778. /**
  1779. * 当前打开的会话 id(用于高亮)。
  1780. *
  1781. * 🔴 左栏提升到 shell 后,页面不再传这个值 ——
  1782. * 改由组件**自己从 URL 解析**(路由形如 `/desktop/chat/:agentId/:sessionId`)。
  1783. * 保留 input 只为兼容可能的显式覆盖(不传时为 null,走 URL)。
  1784. */
  1785. readonly activeSessionId = input<string | null>(null);
  1786. /** 实际高亮的会话:显式传入优先,否则取 URL 末段 */
  1787. protected readonly activeId = computed(() => {
  1788. const explicit = this.activeSessionId();
  1789. if (explicit) return explicit;
  1790. return activeSessionIdOf(this.url());
  1791. });
  1792. /**
  1793. * 请求新建对话 → 由外壳弹「选容器」模态框。
  1794. *
  1795. * 2026-10-30:新建入口收敛为**顶部唯一一个按钮**,所以不再区分"在哪个工作区下新建" ——
  1796. * 工作区归属由用户点完之后在会话行上「移动到工作区」决定,或者直接进日常对话。
  1797. */
  1798. readonly newSession = output<string | null>();
  1799. /** 请求收起左侧栏(由外壳控制骨架列宽) */
  1800. readonly collapse = output<void>();
  1801. /*
  1802. * 一级路由(原 `dk-nav` 顶部导航项,2026-10-30 整体挪进左栏)。
  1803. * 点击只换右侧内容区,左栏保持不动。
  1804. */
  1805. /*
  1806. * 一级菜单来自 NavMenuService(用户可在「更多 → 自定义菜单栏」里排序/隐藏)。
  1807. *
  1808. * 直接展示前 4 项,其余进「更多」。4 是按当前左栏高度定的经验值:
  1809. * 再多会把会话区挤得太短。
  1810. */
  1811. private static readonly PRIMARY_COUNT = 4;
  1812. /** 当前用户能否管理组织成员(2026-11-10):决定「成员管理」入口显不显示 */
  1813. private readonly canManage = computed(() => canManageCompany(this.session.me()));
  1814. /**
  1815. * 对当前用户可见的导航项。
  1816. *
  1817. * 🔴 权限类入口在这里过滤,不靠改 `NAV_CATALOG` ——
  1818. * 后者是**全量目录**,用户自定义排序/显隐要基于它,不能因身份变动而丢项。
  1819. */
  1820. private readonly navItems = computed(() =>
  1821. this.navMenu
  1822. .visible()
  1823. .filter((i) => i.key !== 'admin-users' || this.canManage()),
  1824. );
  1825. protected readonly primaryNav = computed(() =>
  1826. this.navItems().slice(0, SessionTreeComponent.PRIMARY_COUNT),
  1827. );
  1828. protected readonly moreNav = computed(() =>
  1829. this.navItems().slice(SessionTreeComponent.PRIMARY_COUNT),
  1830. );
  1831. /** 「更多」菜单是否展开 */
  1832. protected readonly moreOpen = signal(false);
  1833. /** 浮层位置(跟随「更多」按钮) */
  1834. protected readonly moreMenuPos = signal({ x: 0, y: 0 });
  1835. /**
  1836. * 展开/收起「更多」。
  1837. *
  1838. * 位置按按钮实时矩形计算:贴在按钮右侧、与按钮上沿对齐;
  1839. * 下方空间不足时向上翻,避免被视口底部截断。
  1840. */
  1841. protected toggleMore(e: Event): void {
  1842. if (this.moreOpen()) {
  1843. this.moreOpen.set(false);
  1844. return;
  1845. }
  1846. const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
  1847. const menuH = 12 + this.moreNav().length * 32 + 40;
  1848. const y = r.bottom + menuH > window.innerHeight ? Math.max(8, r.top - menuH) : r.top;
  1849. this.moreMenuPos.set({ x: r.right + 8, y });
  1850. this.moreOpen.set(true);
  1851. }
  1852. /** 「更多」里是否有当前页(决定按钮是否高亮) */
  1853. /** 「更多」里是否有当前页(决定按钮是否高亮) */
  1854. protected readonly moreActive = computed(() =>
  1855. this.moreNav().some((i) => !!i.path && this.isActive(i.path)),
  1856. );
  1857. /** 当前 URL(随 NavigationEnd 更新;OnPush 下必须用 signal) */
  1858. private readonly _url = signal(this.router.url);
  1859. private readonly url = this._url.asReadonly();
  1860. protected isActive(path: string): boolean {
  1861. return navItemActive(this.url(), this.device.mode(), path);
  1862. }
  1863. protected link(path: string): string {
  1864. return `/${this.device.mode()}${path}`;
  1865. }
  1866. /** 会话项显示未读总数 */
  1867. protected badgeFor(path: string): number {
  1868. if (path !== '/chat') return 0;
  1869. return this.liveSvc.agents().reduce((n, a) => n + (a.unread ?? 0), 0);
  1870. }
  1871. protected readonly keyword = signal('');
  1872. protected readonly userMenuOpen = signal(false);
  1873. /** 日常对话是否展开(默认展开,可折叠) */
  1874. protected readonly dailyOpen = signal(true);
  1875. /** 分组列表是否还有下方内容(用于渐隐遮罩,提示"还能滚") */
  1876. protected readonly wsOverflow = signal(false);
  1877. protected readonly dailyOverflow = signal(false);
  1878. protected readonly groupOverflow = signal(false);
  1879. /**
  1880. * 重新测量各分组列表是否溢出。
  1881. *
  1882. * 用 queueMicrotask 延后一帧:数据变化后 DOM 还没重排,立即测会拿到旧高度。
  1883. * 按分组类名取,不再按下标 —— 群聊分组常驻后下标对不上(2026-11-06)。
  1884. */
  1885. private measureOverflow(): void {
  1886. queueMicrotask(() => {
  1887. const over = (sel: string): boolean => {
  1888. const el = document.querySelector(sel);
  1889. return !!el && el.scrollHeight > el.clientHeight + 2;
  1890. };
  1891. this.wsOverflow.set(over('.sect:not(.sect--group) .list-limit'));
  1892. this.groupOverflow.set(over('.sect--group .list-limit'));
  1893. this.dailyOverflow.set(over('.sect.grow .list-limit'));
  1894. });
  1895. }
  1896. private readonly prompt = inject(PromptModalService);
  1897. // ---- 工作区(第 3 轮:接入 WorkspaceService 持久化) ----
  1898. /** 工作区列表来自 service(localStorage 持久化,按用户隔离) */
  1899. protected readonly workspaces = computed(() => this.workspaceSvc.sorted());
  1900. /** 纯 UI 状态:哪些工作区是展开的(不持久化,刷新后默认全部展开) */
  1901. private readonly closedWs = signal<Set<string>>(new Set());
  1902. /** 展开的「已归档」分组(工作区 id 或 `__daily__`) */
  1903. protected readonly archOpen = signal<Set<string>>(new Set());
  1904. constructor() {
  1905. /*
  1906. * 从 localStorage 载入工作区。
  1907. * 🔴 漏掉这一行的症状:能新建、能显示,但**一刷新全没了**,
  1908. * 而且 localStorage 里根本没有对应键(create 时 key() 有值却没人 load 过)。
  1909. */
  1910. this.workspaceSvc.load();
  1911. this.navMenu.load(); // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
  1912. this.chatFont.load();
  1913. /*
  1914. * 会话目录预热(2026-11-01)。
  1915. *
  1916. * 🔴 云端是唯一来源,左栏要列出**所有容器**的对话,就必须逐个容器去拉;
  1917. * 只在进入某容器时拉,用户没进过的容器会一直显示「没有对话」(看起来像丢了)。
  1918. * 拉取失败时 `ready=false`,左栏显示「加载失败 + 重试」,不展示任何缓存。
  1919. */
  1920. effect(() => {
  1921. // 只在登录后、且容器清单首次就绪时拉一轮(LiveService 每 15s 刷新会触发 new 数组)
  1922. if (!this.session.isLoggedIn()) return;
  1923. const ids = this.liveSvc
  1924. .agents()
  1925. .filter((a) => a.matrixReady)
  1926. .map((a) => a.id);
  1927. if (ids.length === 0) return;
  1928. const sig = ids.join(',');
  1929. if (sig === this.loadedAgentsSig) return;
  1930. this.loadedAgentsSig = sig;
  1931. void this.sessionsSvc.reloadAll(ids);
  1932. });
  1933. /*
  1934. * 🔴 窗口重新获得焦点时重拉会话目录(2026-11-09 用户要求)。
  1935. *
  1936. * 用户的诉求:「在一个浏览器操作了,去另一个浏览器看,必须看到最新的」。
  1937. *
  1938. * 原来的 effect 只在**容器清单变化**时拉一次 —— 容器清单基本不变,
  1939. * 于是换标签页 / 换浏览器回来时永远不会重新拉,看到的是上一次的数据。
  1940. *
  1941. * 用 focus 而不是定时轮询:只在用户真正回来时才发请求,零空转流量。
  1942. */
  1943. this.win.addEventListener('focus', this.onWindowFocus);
  1944. // 数据/尺寸变化后重测溢出(工作区与会话列表都是信号,变化即重算)
  1945. effect(() => {
  1946. this.workspaces();
  1947. this.sessionsSvc.all();
  1948. this.dailyOpen();
  1949. this.measureOverflow();
  1950. });
  1951. if (typeof window !== 'undefined') {
  1952. window.addEventListener('resize', () => this.measureOverflow());
  1953. }
  1954. this.router.events.subscribe((e) => {
  1955. if (e instanceof NavigationEnd) this._url.set(e.urlAfterRedirects);
  1956. });
  1957. this._url.set(this.router.url);
  1958. }
  1959. /**
  1960. * 会话所属容器名(2026-11-04)。
  1961. *
  1962. * 左侧列表只显示会话标题时,用户看不出这条对话是跟哪个数字生命聊的 ——
  1963. * 同一容器下常有多条会话,标题相同或相似时尤其容易混淆。
  1964. * 取不到容器时显示占位,不编造。
  1965. */
  1966. protected agentNameOf(s: ChatSession): string {
  1967. return this.liveSvc.agentById(s.agentId)?.name ?? '未知容器';
  1968. }
  1969. /**
  1970. * 群聊天行第二行(2026-11-06)。
  1971. *
  1972. * 群会话的 `agentId` 只是「首成员」,照单聊口径显示容器名会误导 ——
  1973. * 「见微、栖云」底下再写「见微」,用户看不出这是个群。改为成员数。
  1974. */
  1975. protected groupSubline(s: ChatSession): string {
  1976. const n = s.memberIds?.length ?? 0;
  1977. return n > 0 ? `${n} 个容器` : '群聊';
  1978. }
  1979. /** 已经预热过会话目录的容器清单签名(避免 LiveService 轮询触发重复拉取) */
  1980. private loadedAgentsSig = '';
  1981. protected readonly meName = computed(() => this.session.me()?.name ?? '未登录');
  1982. /** 账号辅助行:不是重复主题名,而是「你在这个平台上的身份」 */
  1983. protected readonly userRole = computed(() => (this.session.me() ? '个人账号' : '未登录'));
  1984. /**
  1985. * 全部会话,按归属拆分。
  1986. *
  1987. * 第 1 轮没有 `workspaceId`,所以**所有会话都落在日常对话**——
  1988. * 这是数据层的真实状态,不是视觉降级;第 3 轮接上归属后本方法只改 `ownerOf` 一处。
  1989. */
  1990. /** 群会话(2026-11-05) */
  1991. protected readonly groupSessions = computed(() =>
  1992. this.filter(this.sessionsSvc.all().filter((s) => s.kind === 'group' && !s.archived)),
  1993. );
  1994. /** 日常对话:非群聊、无工作区归属 */
  1995. protected readonly daily = computed(() =>
  1996. this.filter(this.activeList(null)).filter((s) => s.kind !== 'group'),
  1997. );
  1998. /** 会话目录是否可信(false = 云端拉取失败,列表不可展示) */
  1999. protected readonly sessionsReady = this.sessionsSvc.ready;
  2000. protected readonly sessionsError = this.sessionsSvc.error;
  2001. /** 新建会话推送云端失败的原因(2026-11-09) */
  2002. protected readonly pushFailed = this.sessionsSvc.pushFailed;
  2003. /**
  2004. * 重试拉取:按当前打开的容器再拉一次;没有打开任何容器时拉全部容器的列表。
  2005. * 失败仍维持「不可展示」状态,不做任何本地兜底。
  2006. */
  2007. protected retrySessions(): void {
  2008. const agentId = this.chat.agentId();
  2009. if (agentId) {
  2010. void this.chat.open(agentId, this.chat.sessionId() ?? undefined);
  2011. return;
  2012. }
  2013. void this.sessionsSvc.reloadAll(
  2014. this.liveSvc
  2015. .agents()
  2016. .filter((a) => a.matrixReady)
  2017. .map((a) => a.id),
  2018. );
  2019. }
  2020. protected readonly dailyArchived = computed(() =>
  2021. this.filter(
  2022. this.sessionsSvc.all().filter((s) => s.archived && groupOf(s).kind === 'daily'),
  2023. ),
  2024. );
  2025. /** 群聊的已归档(2026-11-08:归档回到群聊分组,不再掉进「日常对话」) */
  2026. protected readonly groupArchived = computed(() =>
  2027. this.filter(
  2028. this.sessionsSvc.all().filter((s) => s.archived && groupOf(s).kind === 'group'),
  2029. ),
  2030. );
  2031. /**
  2032. * 某工作区的活跃会话。
  2033. * 🔴 必须排除群聊:群聊恒不归属工作区,但历史/异常数据里 `workspaceId` 可能被写上,
  2034. * 不排除会出现「同一群聊同时在群聊和工作区两边」。
  2035. */
  2036. protected sessionsIn(wsId: string): ChatSession[] {
  2037. return this.filter(
  2038. this.sessionsSvc
  2039. .all()
  2040. .filter((s) => !s.archived && groupOf(s).kind === 'workspace' && s.workspaceId === wsId),
  2041. );
  2042. }
  2043. protected archivedIn(wsId: string): ChatSession[] {
  2044. return this.filter(
  2045. this.sessionsSvc
  2046. .all()
  2047. .filter((s) => s.archived && groupOf(s).kind === 'workspace' && s.workspaceId === wsId),
  2048. );
  2049. }
  2050. protected sessionTime(ms: number): string {
  2051. const d = new Date(ms);
  2052. const now = new Date();
  2053. const sameDay =
  2054. d.getFullYear() === now.getFullYear() &&
  2055. d.getMonth() === now.getMonth() &&
  2056. d.getDate() === now.getDate();
  2057. const hh = String(d.getHours()).padStart(2, '0');
  2058. const mm = String(d.getMinutes()).padStart(2, '0');
  2059. return sameDay ? `${hh}:${mm}` : `${d.getMonth() + 1}-${d.getDate()}`;
  2060. }
  2061. // ---- 删除会话 ----
  2062. /** 待删除的会话(非 null = 确认框打开) */
  2063. protected readonly removing = signal<ChatSession | null>(null);
  2064. /** 点删除图标 → 先弹确认(删除不可恢复) */
  2065. protected askRemove(s: ChatSession, e: Event): void {
  2066. e.stopPropagation();
  2067. this.removing.set(s);
  2068. }
  2069. /** 确认删除:移除索引 + 云端物理删 + 留墓碑(Matrix 消息不动) */
  2070. protected doRemove(): void {
  2071. const s = this.removing();
  2072. this.removing.set(null);
  2073. if (!s) return;
  2074. this.sessionsSvc.remove(s.id);
  2075. /*
  2076. * 删的正好是当前打开的那条 → 打开该容器下一条,避免停在空白页。
  2077. * 一条都不剩时回到 `/chat/:agentId`(该容器空态)。
  2078. */
  2079. const wasOpen = this._url().includes(`/chat/${s.agentId}/${encodeURIComponent(s.id)}`);
  2080. if (wasOpen) {
  2081. const next = this.sessionsSvc.listFor(s.agentId)[0];
  2082. if (next) this.openSession(next);
  2083. else void this.router.navigateByUrl(`/${this.device.mode()}/chat/${s.agentId}`);
  2084. }
  2085. }
  2086. // ---- 工作区 CRUD ----
  2087. /** 新建工作区 → 输入模态框 */
  2088. protected beginNewWorkspace(): void {
  2089. this.prompt.open({
  2090. title: '新建工作区',
  2091. placeholder: '给工作区起个名字,如「客户端 v2」',
  2092. value: '',
  2093. confirmText: '创建',
  2094. allowEmpty: false,
  2095. /*
  2096. * 🔴 重名校验放在提交前(2026-11-01 用户要求)。
  2097. * 两个同名文件夹在侧栏里完全无法区分,用户点了也不知道进了哪个。
  2098. * 校验失败时弹窗不关闭、当场提示,而不是静默失效。
  2099. */
  2100. validate: (name) =>
  2101. this.workspaceSvc.hasName(name) ? `已经有叫「${name.trim()}」的工作区了` : null,
  2102. onConfirm: (name) => this.workspaceSvc.create(name),
  2103. });
  2104. }
  2105. /** 搜索对话 → 输入模态框(输入即筛选,清空即取消筛选) */
  2106. /**
  2107. * 在指定工作区下新建对话 → 交给外壳弹「选容器」模态框,并带上目标工作区。
  2108. *
  2109. * 2026-10-30 用户反馈:工作区建好后没有任何新建入口(上一轮把分组里的 + 全删了,
  2110. * 结果工作区只能等会话"移进来",无法直接创建)。
  2111. */
  2112. protected newSessionIn(workspaceId: string, e: Event): void {
  2113. e.stopPropagation();
  2114. this.newSession.emit(workspaceId);
  2115. }
  2116. protected openSearch(): void {
  2117. this.prompt.open({
  2118. title: '搜索对话',
  2119. placeholder: '输入对话标题或容器名',
  2120. value: this.keyword(),
  2121. confirmText: '搜索',
  2122. allowEmpty: true,
  2123. onConfirm: (kw) => this.keyword.set(kw.trim()),
  2124. });
  2125. }
  2126. /** 工作区是否展开(默认展开;折叠状态只存内存,不写盘) */
  2127. protected isWsOpen(id: string): boolean {
  2128. return !this.closedWs().has(id);
  2129. }
  2130. protected toggleWorkspace(id: string): void {
  2131. this.closedWs.update((set) => {
  2132. const next = new Set(set);
  2133. if (next.has(id)) next.delete(id);
  2134. else next.add(id);
  2135. return next;
  2136. });
  2137. }
  2138. protected beginRenameWorkspace(w: Workspace, e: Event): void {
  2139. e.stopPropagation();
  2140. this.prompt.open({
  2141. title: '重命名工作区',
  2142. placeholder: '工作区名称',
  2143. value: w.name,
  2144. confirmText: '保存',
  2145. allowEmpty: false,
  2146. // 重命名同样查重(排除自己,否则「不改名直接保存」会被误判)
  2147. validate: (name) =>
  2148. this.workspaceSvc.hasName(name, w.id) ? `已经有叫「${name.trim()}」的工作区了` : null,
  2149. onConfirm: (name) => this.workspaceSvc.rename(w.id, name),
  2150. });
  2151. }
  2152. /**
  2153. * 删除工作区。
  2154. *
  2155. * 🔴 **不删其中的会话** —— 把会话退回「日常对话」。
  2156. * 连带删除会让用户看不到的东西被静默销毁,这是数据丢失,不是"整理"。
  2157. */
  2158. /**
  2159. * 删除工作区 —— **二次确认**(2026-11-01 用户要求)。
  2160. *
  2161. * 删除工作区不是纯 UI 操作:它下面若有对话,会被移回「日常对话」。
  2162. * 不说清楚就直接删,用户会以为对话一起没了。
  2163. */
  2164. protected deleteWorkspace(id: string, e: Event): void {
  2165. e.stopPropagation();
  2166. const w = this.workspaceSvc.byId(id);
  2167. if (w) this.removingWorkspace.set(w);
  2168. }
  2169. /** 待删除的工作区(非 null = 确认框打开) */
  2170. protected readonly removingWorkspace = signal<Workspace | null>(null);
  2171. /** 确认删除工作区:其下会话退回日常对话 */
  2172. protected doDeleteWorkspace(): void {
  2173. const w = this.removingWorkspace();
  2174. this.removingWorkspace.set(null);
  2175. if (!w) return;
  2176. for (const s of this.sessionsSvc.all().filter((x) => x.workspaceId === w.id)) {
  2177. this.sessionsSvc.moveTo(s.id, null);
  2178. }
  2179. this.workspaceSvc.remove(w.id);
  2180. }
  2181. /** 该工作区下的会话数(确认框里说明用) */
  2182. protected sessionCountIn(wsId: string): number {
  2183. return this.sessionsSvc.all().filter((s) => s.workspaceId === wsId).length;
  2184. }
  2185. // ---- 会话操作 ----
  2186. /** 正在挑选移动目标的会话(null = 菜单关闭) */
  2187. protected readonly movingSession = signal<ChatSession | null>(null);
  2188. /** 菜单位置:跟随触发按钮,避免跑到屏幕中央 */
  2189. protected readonly moveMenuPos = signal({ x: 0, y: 0 });
  2190. protected openMoveMenu(s: ChatSession, e: Event): void {
  2191. e.stopPropagation();
  2192. const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
  2193. /* 右对齐到按钮,向下展开;靠近视口底部时向上翻(避免被裁) */
  2194. const menuH = 60 + this.workspaces().length * 34;
  2195. const y = r.bottom + menuH > window.innerHeight ? Math.max(8, r.top - menuH) : r.bottom + 4;
  2196. this.moveMenuPos.set({ x: Math.max(8, r.right - 180), y });
  2197. this.movingSession.set(s);
  2198. }
  2199. protected moveSession(s: ChatSession, wsId: string | null): void {
  2200. this.sessionsSvc.moveTo(s.id, wsId);
  2201. this.movingSession.set(null);
  2202. }
  2203. /** 打开会话:群聊走独立群聊页,单聊走会话页(2026-11-05) */
  2204. protected openSession(s: ChatSession): void {
  2205. void this.router.navigateByUrl(
  2206. s.kind === 'group'
  2207. ? `/${this.device.mode()}/group/${encodeURIComponent(s.id)}`
  2208. : `/${this.device.mode()}/chat/${s.agentId}/${encodeURIComponent(s.id)}`,
  2209. );
  2210. }
  2211. protected toggleArchive(s: ChatSession, e: Event): void {
  2212. e.stopPropagation();
  2213. /*
  2214. * 归档也要二次确认(2026-11-01 用户要求)。
  2215. * 取消归档不需要确认 —— 它是**恢复**操作,误点不会造成损失。
  2216. */
  2217. if (s.archived) {
  2218. this.sessionsSvc.archive(s.id, false);
  2219. return;
  2220. }
  2221. this.archiving.set(s);
  2222. }
  2223. /** 待确认归档的会话(非 null = 确认框打开) */
  2224. protected readonly archiving = signal<ChatSession | null>(null);
  2225. /** 确认归档 */
  2226. protected doArchive(): void {
  2227. const s = this.archiving();
  2228. this.archiving.set(null);
  2229. if (!s) return;
  2230. this.sessionsSvc.archive(s.id, true);
  2231. /*
  2232. * 归档后自动展开**该会话原分组**的「已归档」——让用户看到对话去了哪里。
  2233. * 🔴 键必须与模板一致(2026-11-08):群聊用 `__group__`,工作区用工作区 id,
  2234. * 日常用 `__daily__`。原实现统一用 `owner ?? '__daily__'`,
  2235. * 群聊(workspaceId 为 null)于是展开了日常分组 —— 用户看到归档跑到日常里。
  2236. */
  2237. const g = groupOf(s);
  2238. const key = g.kind === 'workspace' ? g.id : g.kind === 'group' ? '__group__' : '__daily__';
  2239. this.archOpen.update((set) => new Set(set).add(key));
  2240. }
  2241. protected isArchOpen(owner: string): boolean {
  2242. return this.archOpen().has(owner);
  2243. }
  2244. protected toggleArchiveGroup(owner: string): void {
  2245. this.archOpen.update((set) => {
  2246. const next = new Set(set);
  2247. if (next.has(owner)) next.delete(owner);
  2248. else next.add(owner);
  2249. return next;
  2250. });
  2251. }
  2252. // ---- 用户卡菜单 ----
  2253. protected toggleDark(): void {
  2254. this.theme.toggleMode();
  2255. }
  2256. protected goSettings(): void {
  2257. this.userMenuOpen.set(false);
  2258. void this.router.navigateByUrl(`/${this.device.mode()}/mine`);
  2259. }
  2260. // ---- 内部 ----
  2261. /**
  2262. * 会话归属的工作区 id。
  2263. *
  2264. * 若归属的工作区已被删除(其它标签页删的),按「日常对话」处理 ——
  2265. * 否则会话会从界面上彻底消失(悬空引用)。
  2266. */
  2267. /**
  2268. * 会话归属哪个分组。
  2269. *
  2270. * 🔴 2026-11-01:归属**以会话自身的 workspaceId 为准**,
  2271. * 不再因为「工作区列表里暂时没有这个 id」就把会话改判到日常对话。
  2272. *
  2273. * 原实现 `return this.workspaceSvc.byId(id) ? id : null` 的问题是加载时序:
  2274. * 工作区列表比会话到达得晚(或还没 load 完)时,会话会被静默归到日常,
  2275. * 用户看到的就是「在工作区里新建对话,结果跑到下面日常里去了」。
  2276. *
  2277. * 孤儿归属(工作区已被删除)由 `orphanSessions` 单独兜底展示,
  2278. * 不让它在渲染层消失。
  2279. */
  2280. private ownerOf(s: ChatSession): string | null {
  2281. return s.workspaceId ?? null;
  2282. }
  2283. /**
  2284. * 归属指向了不存在工作区的会话(孤儿)。
  2285. *
  2286. * 正常不应出现:删除工作区时会把其下会话退回日常(`WorkspaceService.remove`)。
  2287. * 但历史数据 / 云端归属与本地工作区不同步时会碰到 —— 这些会话**必须可见**,
  2288. * 否则用户会以为对话丢了。
  2289. */
  2290. protected readonly orphanSessions = computed(() => {
  2291. const known = new Set(this.workspaces().map((w) => w.id));
  2292. return this.sessionsSvc
  2293. .all()
  2294. .filter((s) => !s.archived && !!s.workspaceId && !known.has(s.workspaceId));
  2295. });
  2296. /** 某归属下的活跃会话(按更新时间倒序由 service 保证) */
  2297. private activeList(owner: string | null): ChatSession[] {
  2298. return this.sessionsSvc.all().filter((s) => !s.archived && this.ownerOf(s) === owner);
  2299. }
  2300. /**
  2301. * 群聊分组头的「+」:打开「新建对话」并直接停在群聊标签(2026-11-06 用户裁决)。
  2302. * 群聊不归属工作区,目标归属传 null。
  2303. */
  2304. protected beginNewGroup(): void {
  2305. this.newSessionModal.open(null, 'group');
  2306. }
  2307. /** 关键字过滤(标题或容器名命中) */
  2308. private filter(list: ChatSession[]): ChatSession[] {
  2309. const q = this.keyword().trim().toLowerCase();
  2310. if (!q) return list;
  2311. return list.filter((s) => {
  2312. if (s.title.toLowerCase().includes(q)) return true;
  2313. const agent = this.liveSvc.agentById(s.agentId);
  2314. return (agent?.name ?? '').toLowerCase().includes(q);
  2315. });
  2316. }
  2317. /** 容器在线色调(供后续行内状态点使用;保留以免第 3 轮重写) */
  2318. protected toneOf(agentId: string): string {
  2319. return agentStatusTone(this.liveSvc.agentById(agentId)?.status as never);
  2320. }
  2321. /**
  2322. * 会话所属容器的状态(2026-11-08)。
  2323. *
  2324. * 用 `resolveAgentState` 而不是裸 `status`:后者不看心跳,
  2325. * 45 天没心跳的僵尸容器会被涂成绿色「正在执行」。
  2326. * 与移动端会话列表、新建弹窗同一口径。
  2327. */
  2328. protected agentStateOf(s: ChatSession): AgentStateView {
  2329. return resolveAgentState({ agent: this.liveSvc.agentById(s.agentId) });
  2330. }
  2331. protected unreadOf(agentId: string): number {
  2332. return this.unreadSvc.countOf(agentId);
  2333. }
  2334. /** 重新同步失败的待推会话(用户点「重新同步」) */
  2335. protected retryPush(): void {
  2336. const id = this.chat.agentId();
  2337. if (id) void this.sessionsSvc.pushAll(id);
  2338. }
  2339. ngOnDestroy(): void {
  2340. // 组件销毁时必须摘掉监听,否则切页面后旧的实例仍在响应 focus
  2341. this.win.removeEventListener('focus', this.onWindowFocus);
  2342. }
  2343. }