session-tree.ts 82 KB

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