session-tree.ts 91 KB

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