session-tree.ts 86 KB

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