session-tree.ts 88 KB

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