| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285 |
- import { NgTemplateOutlet } from '@angular/common';
- import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
- import { NavigationEnd, Router, RouterLink } from '@angular/router';
- import { effect } from '@angular/core';
- import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
- import { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
- import { ChatSession } from '../../../core/chat-session';
- import { ChatSessionService } from '../../../core/chat-session.service';
- import { Workspace, WorkspaceService } from '../../../core/workspace.service';
- import { PromptModalService } from '../prompt-modal/prompt-modal.service';
- import { NavMenuService } from '../../../core/nav-menu.service';
- import { HoverScrollDirective } from './hover-scroll.directive';
- import { ChatFontService } from '../../../core/chat-font.service';
- import { NavCustomModalService } from '../nav-custom-modal/nav-custom-modal.service';
- import { NewSessionModalService } from '../new-session-modal/new-session-modal.service';
- import { activeSessionIdOf } from './active-session';
- import { DeviceService } from '../../../core/device.service';
- import { LiveService } from '../../../core/live.service';
- import { PALETTES, PaletteService } from '../../../core/palette.service';
- import { SessionService } from '../../../core/session.service';
- import { ThemeService } from '../../../ui/theme';
- import { UnreadService } from '../../../core/unread.service';
- import { ChatService } from '../../../core/chat.service';
- import { agentStatusTone } from '../../../core/api/models';
- /** 工作区(用户手动新建的文件夹) */
- /** 一次「选择容器并新建对话」的请求(由页面弹模态框处理) */
- export interface NewSessionRequest {
- /** 目标工作区;`null` = 日常对话 */
- workspaceId: string | null;
- }
- /**
- * 桌面·三段式会话树(工作区 / 日常对话 / 已归档)+ 底部用户卡
- *
- * 依据 `docs/02-design/plans/2026-10-29-桌面布局改造-顶部导航与三段式会话树.md`:
- * · D2 工作区 = 用户手动新建的文件夹(自取名 / 重命名 / 删除)
- * · D4 日常对话 = 未归入工作区的会话,单一平铺
- * · D5 各自归档:每个工作区内部、日常对话内部各有一份「已归档」
- * · D6 用户卡在树底部,弹出:账号 / 主题 / 设置
- *
- * ## 🔴 本轮(第 1 轮)范围:只做布局骨架
- *
- * 工作区是**组件内内存态**(`signal<Workspace[]>`),刷新即丢失;
- * `ChatSession.workspaceId` 尚未落库,因此所有会话当前都归在「日常对话」下。
- * 持久化与「会话→工作区」归属在第 3 轮接入 —— 接口(输入/输出)已按最终形态定义,
- * 第 3 轮只换数据源,模板不用改。
- */
- @Component({
- selector: 'app-session-tree',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- NgTemplateOutlet,
- RouterLink,
- UiAvatar,
- UiButton,
- UiEmpty,
- UiInput,
- UiModal,
- UiSkeleton,
- UiSwitch,
- UiTooltip,
- HoverScrollDirective,
- ],
- template: `
- <nav class="tree" aria-label="会话目录">
- <!--
- ========== 品牌区(2026-10-30 布局重做) ==========
- 顶部导航栏整体取消后,品牌信息落到左栏最上方。
- -->
- <div class="brand-row">
- <a class="brand" [routerLink]="link('/chat')" aria-label="Fmode 首页">
- <!--
- 品牌文字左对齐到下方菜单图标(2026-10-30 用户要求):
- 去掉图标后给一个等宽占位,让 "Fmode" 的起点与「新建对话」的图标列对齐。
- -->
- <span class="brand-word">Fmode</span>
- </a>
- <!--
- 折叠按钮(2026-10-30 用户要求"隐藏式折叠"):
- 悬停品牌行才出现,不常驻占位 —— 常驻会把品牌区撑宽、也和「更多」的箭头重复。
- -->
- <button
- type="button"
- class="collapse-btn"
- aria-label="收起左侧栏"
- title="收起左侧栏"
- (click)="collapse.emit()"
- >
- <i class="fa-solid fa-angles-left" aria-hidden="true"></i>
- </button>
- </div>
- <!--
- ========== 一级菜单(2026-10-30 可由用户在「更多 → 自定义菜单栏」里调整) ==========
- 数据源是 NavMenuService 的 visible():
- · 前 primaryCount 项直接列出
- · 其余进「更多」浮层
- 动作型项(path 为 null,如「新建对话」)点击不跳路由,而是触发动作。
- -->
- <nav class="routes" aria-label="主导航">
- @for (item of primaryNav(); track item.key) {
- @if (item.path) {
- <a
- class="route"
- [routerLink]="link(item.path)"
- [class.active]="isActive(item.path)"
- [attr.aria-current]="isActive(item.path) ? 'page' : null"
- >
- <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
- <span class="route-label">{{ item.label }}</span>
- @if (item.path && badgeFor(item.path) > 0) {
- <span class="route-badge">{{ badgeFor(item.path) }}</span>
- }
- </a>
- } @else {
- <!-- 动作项:新建对话 -->
- <button type="button" class="route route-action" (click)="newSession.emit(null)">
- <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
- <span class="route-label">{{ item.label }}</span>
- </button>
- }
- }
- <!-- 「更多」:收纳其余项,点击展开浮层 -->
- @if (moreNav().length > 0) {
- <button
- type="button"
- class="route route-more"
- [class.active]="moreActive() || moreOpen()"
- [attr.aria-expanded]="moreOpen()"
- aria-haspopup="menu"
- (click)="toggleMore($event)"
- >
- <i class="fa-solid fa-grip" aria-hidden="true"></i>
- <span class="route-label">更多</span>
- <i class="fa-solid fa-chevron-down more-caret" aria-hidden="true"></i>
- </button>
- }
- </nav>
- @if (moreOpen()) {
- <div class="menu-backdrop" aria-hidden="true" (click)="moreOpen.set(false)"></div>
- <div
- class="more-menu"
- role="menu"
- aria-label="更多入口"
- [style.top.px]="moreMenuPos().y"
- [style.left.px]="moreMenuPos().x"
- >
- @for (item of moreNav(); track item.key) {
- @if (item.path) {
- <a
- class="more-item"
- role="menuitem"
- [routerLink]="link(item.path)"
- [class.active]="isActive(item.path)"
- (click)="moreOpen.set(false)"
- >
- <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
- <span class="route-label">{{ item.label }}</span>
- </a>
- } @else {
- <button
- type="button"
- class="more-item"
- role="menuitem"
- (click)="moreOpen.set(false); newSession.emit(null)"
- >
- <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
- <span class="route-label">{{ item.label }}</span>
- </button>
- }
- }
- <hr class="more-line" />
- <!-- 自定义菜单栏:拖拽排序 + 显隐 -->
- <button
- type="button"
- class="more-item"
- role="menuitem"
- (click)="moreOpen.set(false); navCustom.open()"
- >
- <i class="fa-solid fa-sliders" aria-hidden="true"></i>
- <span class="route-label">自定义菜单栏</span>
- </button>
- </div>
- }
- <div class="tree-scroll">
- <!-- ========== 工作区 ========== -->
- <section class="sect">
- <header class="sect-head">
- <!-- 对齐槽:与「日常对话」的折叠箭头同宽,三个分组标题文字左缘一致(2026-11-06) -->
- <span class="sect-lead" aria-hidden="true">
- <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
- </span>
- <span class="sect-name">工作区</span>
- <span class="sect-count">{{ workspaces().length }}</span>
- <ui-tooltip content="新建工作区">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-plus"
- ariaLabel="新建工作区"
- (click)="beginNewWorkspace()"
- />
- </ui-tooltip>
- </header>
- @if (workspaces().length === 0) {
- <button type="button" class="empty-add" (click)="beginNewWorkspace()">
- <i class="fa-solid fa-plus" aria-hidden="true"></i>
- 新建工作区,按项目分开存放对话
- </button>
- }
- <div class="list-limit" [class.more-below]="wsOverflow()">
- @for (w of workspaces(); track w.id) {
- <div class="ws">
- <div
- class="ws-head"
- role="button"
- tabindex="0"
- [attr.aria-expanded]="isWsOpen(w.id)"
- (click)="toggleWorkspace(w.id)"
- (keydown.enter)="toggleWorkspace(w.id)"
- >
- <i
- class="caret fa-solid"
- [class.fa-chevron-right]="!isWsOpen(w.id)"
- [class.fa-chevron-down]="isWsOpen(w.id)"
- aria-hidden="true"
- ></i>
- <i class="fa-solid fa-folder" aria-hidden="true"></i>
- <span class="ws-name ellipsis" [title]="w.name">{{ w.name }}</span>
- <span class="ws-acts">
- <ui-tooltip content="在本工作区新建对话">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-plus"
- [ariaLabel]="'在 ' + w.name + ' 下新建对话'"
- (click)="newSessionIn(w.id, $event)"
- />
- </ui-tooltip>
- <ui-tooltip content="重命名">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-pen"
- [ariaLabel]="'重命名工作区 ' + w.name"
- (click)="beginRenameWorkspace(w, $event)"
- />
- </ui-tooltip>
- <ui-tooltip content="删除">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-trash"
- [ariaLabel]="'删除工作区 ' + w.name"
- (click)="deleteWorkspace(w.id, $event)"
- />
- </ui-tooltip>
- </span>
- </div>
- @if (isWsOpen(w.id)) {
- <div class="ws-body">
- @if (sessionsIn(w.id).length === 0) {
- <p class="sect-hint inset">还没有对话</p>
- }
- @for (s of sessionsIn(w.id); track s.id) {
- <ng-container
- *ngTemplateOutlet="row; context: { $implicit: s, owner: w.id }"
- ></ng-container>
- }
- @if (archivedIn(w.id).length > 0) {
- <button
- type="button"
- class="arch-toggle"
- [attr.aria-expanded]="isArchOpen(w.id)"
- (click)="toggleArchiveGroup(w.id)"
- >
- <i
- class="fa-solid"
- [class.fa-chevron-right]="!isArchOpen(w.id)"
- [class.fa-chevron-down]="isArchOpen(w.id)"
- aria-hidden="true"
- ></i>
- 已归档 <span class="arch-count">{{ archivedIn(w.id).length }}</span>
- </button>
- @if (isArchOpen(w.id)) {
- @for (s of archivedIn(w.id); track s.id) {
- <ng-container
- *ngTemplateOutlet="row; context: { $implicit: s, owner: w.id }"
- ></ng-container>
- }
- }
- }
- </div>
- }
- </div>
- }
- </div>
- </section>
- <!-- ========== 群聊(2026-11-05):夹在工作区与日常之间 ========== -->
- <!--
- 群聊分组头与「工作区」同构(2026-11-06 用户要求):
- 标题在左、计数靠右、最右侧「+」新建群聊。此前是「大标题 + 计数字」,
- 和上下两个分组头排版不一致,看起来像另一套组件。
- 群聊不归属工作区,也没有「移动到工作区」,所以不必做可折叠 ——
- 折叠箭头留给有下一级归属的「日常对话」。
- -->
- <section class="sect sect--group">
- <header class="sect-head">
- <span class="sect-lead" aria-hidden="true">
- <i class="fa-solid fa-users" aria-hidden="true"></i>
- </span>
- <span class="sect-name">群聊</span>
- <span class="sect-count">{{ groupSessions().length }}</span>
- <ui-tooltip content="新建群聊">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-plus"
- ariaLabel="新建群聊"
- (click)="beginNewGroup()"
- />
- </ui-tooltip>
- </header>
- <!-- 群列表与工作区/日常同一套内部滚动:分组再多也不会把下面的分组顶出可视区 -->
- <div class="list-limit" [class.more-below]="groupOverflow()">
- @if (groupSessions().length === 0) {
- <button type="button" class="empty-add" (click)="beginNewGroup()">
- <i class="fa-solid fa-plus" aria-hidden="true"></i>
- 新建群聊,把多个数字生命拉进同一个对话
- </button>
- }
- @for (s of groupSessions(); track s.id) {
- <ng-container
- *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"
- />
- }
- </div>
- </section>
- <!-- ========== 日常对话(默认展开、可折叠、与工作区平分剩余高度) ========== -->
- <section class="sect" [class.grow]="dailyOpen()">
- <header class="sect-head">
- <button
- type="button"
- class="sect-toggle"
- [attr.aria-expanded]="dailyOpen()"
- (click)="dailyOpen.set(!dailyOpen())"
- >
- <span class="sect-lead">
- <i
- class="caret fa-solid"
- [class.fa-chevron-right]="!dailyOpen()"
- [class.fa-chevron-down]="dailyOpen()"
- aria-hidden="true"
- ></i>
- </span>
- <span class="sect-name">日常对话</span>
- <span class="sect-count">{{ daily().length }}</span>
- </button>
- </header>
- @if (dailyOpen()) {
- <div class="list-limit" [class.more-below]="dailyOverflow()">
- @if (live.state() === 'loading' && daily().length === 0) {
- <div class="skel">
- @for (i of [1, 2, 3]; track i) {
- <ui-skeleton variant="rect" height="32px" />
- }
- </div>
- } @else if (!sessionsReady()) {
- <!--
- 会话目录拉取失败(2026-11-01 用户裁决):**不显示任何会话**。
- 这里必须与「还没有对话」区分开 —— 空态意味着「你确实没有」,
- 而实际是「我们不知道你有没有」。混为一谈会让用户以为对话被删了,
- 从而重复新建,进一步制造脏数据。
- -->
- <div class="sect-error" role="alert">
- <p>会话目录加载失败,暂时无法显示对话。</p>
- <p class="sect-error-detail">{{ sessionsError() }}</p>
- <button type="button" class="retry" (click)="retrySessions()">
- <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> 重试
- </button>
- </div>
- } @else if (daily().length === 0) {
- <p class="sect-hint">日常新建的对话会出现在这里。</p>
- }
- @for (s of daily(); track s.id) {
- <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
- }
- <!--
- 孤儿归属兜底(2026-11-01):归属于某个已不存在的工作区。
- 不单独列出的话,这些会话在界面上彻底消失,用户会以为对话丢了。
- 点开仍可正常进入,移动工作区即可修正归属。
- -->
- @if (orphanSessions().length > 0) {
- <p class="sect-hint orphan-hint">
- 以下 {{ orphanSessions().length }} 条对话所属的工作区已不存在,可移动到其它工作区:
- </p>
- @for (s of orphanSessions(); track s.id) {
- <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
- }
- }
- @if (dailyArchived().length > 0) {
- <button
- type="button"
- class="arch-toggle"
- [attr.aria-expanded]="isArchOpen('__daily__')"
- (click)="toggleArchiveGroup('__daily__')"
- >
- <i
- class="fa-solid"
- [class.fa-chevron-right]="!isArchOpen('__daily__')"
- [class.fa-chevron-down]="isArchOpen('__daily__')"
- aria-hidden="true"
- ></i>
- 已归档 <span class="arch-count">{{ dailyArchived().length }}</span>
- </button>
- @if (isArchOpen('__daily__')) {
- @for (s of dailyArchived(); track s.id) {
- <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
- }
- }
- }
- </div>
- }
- </section>
- </div>
- <!-- ========== 底部用户卡(D6) ========== -->
- <div class="user-card">
- <button
- type="button"
- class="user-main"
- [attr.aria-expanded]="userMenuOpen()"
- aria-haspopup="menu"
- (click)="userMenuOpen.set(!userMenuOpen())"
- >
- <span class="user-avatar">
- <ui-avatar [name]="meName()" [size]="'sm'" />
- <!-- 在线状态点:账号区需要一眼看出"连得上还是掉线了" -->
- <span class="user-dot" [class.off]="live.state() !== 'ready'" aria-hidden="true"></span>
- </span>
- <span class="user-text">
- <span class="user-name ellipsis">{{ meName() }}</span>
- <span class="user-role ellipsis">{{ userRole() }}</span>
- </span>
- <!--
- 箭头方向由 CSS 控制(见 .user-main:hover > .dots):
- 默认朝上,悬停或菜单展开时朝下。不在模板里切类名,
- 避免「模板状态」与「悬停状态」两套逻辑打架。
- -->
- <i class="fa-solid fa-chevron-up dots" aria-hidden="true"></i>
- </button>
- <!-- 点外部关闭 -->
- @if (userMenuOpen()) {
- <div class="menu-backdrop" aria-hidden="true" (click)="userMenuOpen.set(false)"></div>
- <div class="user-menu" role="menu">
- <!--
- 明暗开关:用 ui-switch 直接表达「开/关」状态,
- 比「点一下切到下一边」的按钮更符合用户对主题的认知(当前已经是深色还是浅色,一眼可见)。
- -->
- <div class="menu-row">
- <i class="fa-solid fa-circle-half-stroke" aria-hidden="true"></i>
- <span class="menu-row-text">深色模式</span>
- <!-- ui-switch 内部自带 click → toggle;用 model 双向绑定回写即为唯一入口 -->
- <ui-switch
- [checked]="theme.isDark()"
- size="sm"
- ariaLabel="深色模式"
- (checkedChange)="theme.setMode($event ? 'dark' : 'light')"
- />
- </div>
- <p class="menu-label">配色</p>
- <!--
- 配色项(2026-10-30 按 demo 优化):
- 原来是「灰圆点 + 名称」,未选中项那排黑圆点非常抢眼、且不表达任何信息。
- 改成 demo 的做法:**色块**(真实取色)+ 名称,选中才出现对勾。
- -->
- @for (p of palettes; track p.id) {
- <button
- type="button"
- class="swatch-item"
- role="menuitemradio"
- [attr.aria-checked]="palette.palette() === p.id"
- (click)="palette.set(p.id)"
- >
- <span class="swatch" [attr.data-preview-palette]="p.id" aria-hidden="true">
- <span class="swatch-in"></span>
- </span>
- <span class="swatch-name">{{ p.name }}</span>
- @if (palette.palette() === p.id) {
- <i class="fa-solid fa-check swatch-check" aria-hidden="true"></i>
- }
- </button>
- }
- <hr class="menu-line" />
- <button type="button" class="menu-item" role="menuitem" (click)="goSettings()">
- <i class="fa-solid fa-gear" aria-hidden="true"></i>账号与设置
- </button>
- </div>
- }
- </div>
- <!--
- 移动到工作区的浮层菜单。
- 用 fixed 定位而不是 absolute:侧栏内容区是 overflow:auto 的滚动容器,
- absolute 弹层会被裁掉(规范 §三-13 明确要求浮层走 fixed)。
- -->
- @if (movingSession(); as mv) {
- <div class="move-backdrop" aria-hidden="true" (click)="movingSession.set(null)"></div>
- <div
- class="move-menu"
- role="menu"
- [style.left.px]="moveMenuPos().x"
- [style.top.px]="moveMenuPos().y"
- >
- <p class="menu-label">移动到</p>
- <button
- type="button"
- class="menu-item"
- role="menuitemradio"
- [attr.aria-checked]="!mv.workspaceId"
- (click)="moveSession(mv, null)"
- >
- <i class="fa-solid" [class.fa-check]="!mv.workspaceId" [class.fa-minus]="!!mv.workspaceId" aria-hidden="true"></i>
- 日常对话
- </button>
- @for (w of workspaces(); track w.id) {
- <button
- type="button"
- class="menu-item"
- role="menuitemradio"
- [attr.aria-checked]="mv.workspaceId === w.id"
- (click)="moveSession(mv, w.id)"
- >
- <i class="fa-solid" [class.fa-check]="mv.workspaceId === w.id" [class.fa-minus]="mv.workspaceId !== w.id" aria-hidden="true"></i>
- {{ w.name }}
- </button>
- }
- </div>
- }
- </nav>
- <!-- 删除工作区确认(2026-11-01) -->
- <ui-modal
- [open]="removingWorkspace() !== null"
- title="删除这个工作区?"
- size="sm"
- (openChange)="$event || removingWorkspace.set(null)"
- >
- <p class="rm-note">
- 将删除工作区「{{ removingWorkspace()?.name }}」。
- @if (removingWorkspace(); as rw) {
- @if (sessionCountIn(rw.id) > 0) {
- 其中的 <strong>{{ sessionCountIn(rw.id) }} 条对话会被移回「日常对话」,不会被删除</strong>。
- } @else {
- 这个工作区里没有对话。
- }
- }
- </p>
- <div class="rm-acts">
- <ui-button variant="ghost" (click)="removingWorkspace.set(null)">取消</ui-button>
- <ui-button variant="danger" (click)="doDeleteWorkspace()">删除工作区</ui-button>
- </div>
- </ui-modal>
- <!-- 归档确认(2026-11-01 用户要求:归档也要二次提示) -->
- <ui-modal
- [open]="archiving() !== null"
- title="归档这条对话?"
- size="sm"
- (openChange)="$event || archiving.set(null)"
- >
- <p class="rm-note">
- 「{{ archiving()?.title }}」会移到「已归档」里,随时可以取消归档恢复。
- </p>
- <div class="rm-acts">
- <ui-button variant="ghost" (click)="archiving.set(null)">取消</ui-button>
- <ui-button variant="primary" (click)="doArchive()">归档</ui-button>
- </div>
- </ui-modal>
- <!--
- 删除确认(2026-11-01 补上删除入口)。
- 删除类操作一律走居中 ui-modal 二次确认,不用 popconfirm(规范 18 §三-13)。
- 文案必须说清「消息不会删」——否则用户会以为内容丢了。
- -->
- <ui-modal
- [open]="removing() !== null"
- title="删除这条对话?"
- size="sm"
- (openChange)="$event || removing.set(null)"
- >
- <p class="rm-note">
- 将从列表删除「{{ removing()?.title }}」,删除后<strong>不可恢复</strong>。
- </p>
- <div class="rm-acts">
- <ui-button variant="ghost" (click)="removing.set(null)">取消</ui-button>
- <ui-button variant="danger" (click)="doRemove()">删除</ui-button>
- </div>
- </ui-modal>
- <!-- 会话行模板(工作区与日常共用;owner=null 表示日常) -->
- <ng-template #row let-s let-owner="owner">
- <div
- class="sess"
- [class.active]="activeId() === s.id"
- [class.archived]="s.archived"
- role="button"
- tabindex="0"
- (click)="openSession(s)"
- (keydown.enter)="openSession(s)"
- >
- <!--
- 会话行(2026-10-30 对齐参考图):
- · 每行有独立的浅色底 + 圆角,行与行之间留缝
- · 标题超长时**左右渐隐**(不是硬截断成省略号)
- · 时间靠右、弱化;悬停时切换为操作按钮
- · 标题溢出时鼠标移入向左滚动看全文(appHoverScroll)
- -->
- <span class="sess-title-wrap">
- <span class="sess-title" appHoverScroll [title]="s.title">{{ s.title }}</span>
- <!--
- 容器名(2026-11-04):明确「在跟哪个数字生命对话」。
- 群聊没有单一容器,名字串由群标题表达,这里换成成员数(2026-11-06)。
- -->
- <span class="sess-agent" [title]="s.kind === 'group' ? groupSubline(s) : agentNameOf(s)">
- {{ s.kind === 'group' ? groupSubline(s) : agentNameOf(s) }}
- </span>
- </span>
- <!-- 左侧列表不显示时间(2026-10-31 澄清:时间指聊天区消息):操作图标常驻 -->
- <span class="sess-acts">
- <!--
- 移动到工作区(2026-10-30):会话一旦建在日常对话里,
- 没有这个入口就永远归不进工作区 —— 工作区会变成"只能新建时装进去"。
- -->
- @if (workspaces().length > 0) {
- <ui-tooltip content="移动到工作区">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-folder-tree"
- [ariaLabel]="'移动 ' + s.title + ' 到工作区'"
- (click)="openMoveMenu(s, $event)"
- />
- </ui-tooltip>
- }
- <ui-tooltip [content]="s.archived ? '取消归档' : '归档'">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- [icon]="s.archived ? 'fa-solid fa-box-open' : 'fa-solid fa-box-archive'"
- [ariaLabel]="s.archived ? '取消归档 ' + s.title : '归档 ' + s.title"
- (click)="toggleArchive(s, $event)"
- />
- </ui-tooltip>
- <!--
- 删除(2026-11-01 用户反馈「左侧只有归档没有删除」补上)。
- 删除 = 移除会话索引,云端记录物理删除 + 留墓碑;Matrix 里的消息不动。
- 必须先弹确认 —— 删除不可恢复。
- -->
- <ui-tooltip content="删除">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-trash-can"
- [ariaLabel]="'删除 ' + s.title"
- (click)="askRemove(s, $event)"
- />
- </ui-tooltip>
- </span>
- </div>
- </ng-template>
- `,
- styles: [
- `
- :host {
- display: block;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- }
- .tree {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- /*
- * 禁止选中/复制(2026-10-30 用户要求):
- * 左栏是导航区,误拖选会产生"文字被刷蓝"的噪音,看起来像界面出错。
- * 输入框需要保留可选中,所以单独在 .inline-input / ui-input 上放行。
- */
- user-select: none;
- -webkit-user-select: none;
- }
- /* 输入类元素必须能选中文本,否则无法编辑/复制自己输入的内容 */
- .tree input,
- .tree textarea {
- user-select: text;
- -webkit-user-select: text;
- /*
- * 底色用 canvas 与主区一致(2026-10-29 对齐 ChatGPT 侧栏观感):
- * 原来整块 surface 白底 + 右边框,像一张贴在左边的卡片;
- * 改成同底色、只留一道极淡的边,左栏与对话流更像同一块界面。
- */
- background: var(--canvas);
- border-right: 1px solid var(--edge);
- }
- /*
- * 搜索框(2026-10-29 对齐 ChatGPT 侧栏):
- * ui-input 自带明显边框,在侧栏里视觉重量过大 —— 用 :host 内的穿透无法做,
- * 改为外层容器收紧内边距,让输入框自己占满,减少两侧空白带来的"卡在中间"感。
- */
- /* ---- 品牌区 ---- */
- /*
- * 品牌行(2026-10-30 按参考图调整):
- * · 去掉品牌图标,只留文字
- * · 文字左缘与下方菜单**图标列**对齐(不是与图标左边距对齐)
- * · 行高 38 → 48,字号 16 → 20,整体更有"标题"分量
- */
- .brand-row {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- /* 左内边距与 .routes 一致,文字起点再对齐到图标列(见 .brand-word) */
- padding: 0 var(--sp-2) 0 var(--sp-2);
- }
- .brand {
- display: flex;
- flex: 1;
- min-width: 0;
- align-items: center;
- min-height: 48px;
- /* 图标列宽 16 + 图标与文字间距 12 → 文字起点与菜单项文字列对齐 */
- padding-left: var(--sp-3);
- flex-shrink: 0;
- text-decoration: none;
- color: var(--ink);
- }
- .brand-mark {
- display: inline-grid;
- place-items: center;
- /* 与折叠按钮同尺寸,两者在品牌行里水平对齐 */
- width: 30px;
- height: 30px;
- border-radius: var(--r-sm);
- background: var(--nav-surface);
- color: var(--nav-brand);
- font-size: var(--fs-12);
- }
- .brand-word {
- font-size: var(--fs-20);
- font-weight: 700;
- letter-spacing: -0.5px;
- color: var(--ink);
- }
- /*
- * 折叠按钮(2026-10-30 按用户要求调整):
- * · **常驻显示**(原来悬停才出现,用户找不到入口)
- * · 尺寸放大到与品牌图标同级(30px),垂直居中对齐
- */
- .collapse-btn {
- display: grid;
- place-items: center;
- width: 30px;
- height: 30px;
- flex-shrink: 0;
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--muted);
- font-size: var(--fs-14);
- cursor: pointer;
- transition: background-color var(--motion-fast) var(--ease-standard),
- color var(--motion-fast) var(--ease-standard);
- }
- .collapse-btn:hover {
- background: var(--surface-hover);
- color: var(--ink);
- }
- /* ---- 一级路由 ---- */
- .routes {
- display: grid;
- gap: 0;
- padding: 0 var(--sp-2) var(--sp-1);
- flex-shrink: 0;
- }
- .route {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- /*
- * 行高与字号(2026-10-30 按参考图放大):30→38、13→14。
- * 参考图的导航行明显更松、字更实,30px 在高分屏上偏挤。
- */
- min-height: 38px;
- padding: 0 var(--sp-3);
- border-radius: var(--r-md);
- color: var(--ink-2);
- font-size: var(--fs-14);
- text-decoration: none;
- transition: background-color var(--motion-fast) var(--ease-standard);
- }
- .route:hover {
- background: var(--surface-hover);
- color: var(--ink);
- }
- .route.active {
- background: var(--surface-hover);
- color: var(--ink);
- font-weight: 600;
- }
- .route > i {
- /* 图标列固定 18px:品牌文字的左缘就是靠它算出来的对齐基准 */
- width: 18px;
- flex-shrink: 0;
- text-align: center;
- color: var(--muted);
- font-size: var(--fs-14);
- }
- .route.active > i {
- color: var(--primary);
- }
- .route-label {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- /*
- * 「更多」按钮(2026-10-30 修悬停底色不满行):
- * 它是 <button>,默认盒模型下宽度按内容算 —— 即使写了 width:100%,
- * 因为没有 padding/border 归零的显式声明,视觉上也比 <a> 行窄一截,
- * 悬停底色右侧会缺一块(用户实测)。
- * 这里把盒模型与 <a> 行完全对齐:100% 宽 + 同一套 padding + box-sizing。
- */
- .route-more {
- width: 100%;
- box-sizing: border-box;
- border: 0;
- background: transparent;
- cursor: pointer;
- font: inherit;
- text-align: left;
- /* 与 .route 的左右内边距一致,保证文字起点对齐 */
- padding: 0 var(--sp-3);
- }
- .more-caret {
- font-size: var(--fs-11);
- color: var(--faint);
- transition: transform var(--motion-fast) var(--ease-standard);
- }
- .route-more[aria-expanded='true'] .more-caret {
- transform: rotate(180deg);
- }
- /* 「更多」浮层:fixed 定位,避免被侧栏 overflow 裁切 */
- .more-menu {
- position: fixed;
- z-index: calc(var(--z-popover) + 1);
- min-width: 176px;
- padding: var(--sp-1);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- box-shadow: var(--shadow-pop);
- /* 位置由 JS 按「更多」按钮的实时位置算出(fixed 定位,不被侧栏裁切) */
- }
- .more-line {
- margin: var(--sp-1) 0;
- border: 0;
- border-top: 1px solid var(--edge);
- }
- /* 动作型路由项(新建对话)用 button 渲染,需要复刻 <a> 的样式 */
- .route-action {
- width: 100%;
- border: 0;
- background: transparent;
- font: inherit;
- text-align: left;
- cursor: pointer;
- }
- .more-item {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- /* 与一级导航 .route 对齐:38px 行高 + 显式内边距,悬停高亮块与文字行一样宽高 */
- width: 100%;
- min-height: 38px;
- padding: 0 var(--sp-3);
- border: 0;
- border-radius: var(--r-md);
- background: transparent;
- color: var(--ink);
- font: inherit;
- font-size: var(--fs-14);
- text-align: left;
- text-decoration: none;
- cursor: pointer;
- }
- .more-item:hover {
- background: var(--surface-hover);
- }
- .more-item:focus-visible {
- outline: 2px solid var(--primary);
- outline-offset: -2px;
- }
- .more-item.active {
- background: var(--surface-active);
- font-weight: 600;
- }
- .more-item > i {
- width: 16px;
- text-align: center;
- color: var(--muted);
- }
- .more-item.active > i {
- color: var(--primary);
- }
- .route-badge {
- min-width: 18px;
- padding: 0 5px;
- border-radius: var(--r-tag);
- background: var(--danger);
- color: var(--on-danger);
- font-size: var(--fs-11);
- line-height: 18px;
- text-align: center;
- }
- /*
- * 会话区(2026-10-30 重做):
- *
- * 🔴 原来整块 tree-scroll 可滚 + 每个分组再限高 34vh ——
- * 结果工作区内容一多,整块被撑满,「日常对话」被挤到需要滚动才能看见,
- * 用户以为它消失了(实测:seed 29 条会话后日常对话分组直接不可见)。
- *
- * 现在的规则:
- * · 外层**不滚**(flex 列,min-height: 0)
- * · 每个分组按内容分配高度,**各自内部滚动**(flex: 1 1 auto + min-height: 0)
- * · 分组标题固定,永远看得到「工作区 / 日常对话」两个名字
- */
- .tree-scroll {
- display: flex;
- flex-direction: column;
- gap: var(--sp-3);
- flex: 1;
- min-height: 0;
- overflow: hidden;
- padding: 0 var(--sp-2) var(--sp-3);
- }
- /*
- * 分组高度分配(2026-10-30 第二次修正):
- * 第一版用 0 1 auto —— flex 子项按内容撑高,工作区一多就把整块占满,
- * 下面的「日常对话」直接被顶出可视区(实测 4 个工作区 29 条会话时复现)。
- *
- * 现在:
- * · 工作区 → 最多 45% 高度,超出自己滚
- * · 日常对话 → 吃掉剩余高度(flex: 1),始终可见
- */
- .sect {
- display: flex;
- flex-direction: column;
- min-height: 0;
- flex: 0 1 auto;
- /*
- * 🔴 用绝对值而不是 45%:tree-scroll 的父链上有多个 min-height:0 的 flex 容器,
- * 百分比 max-height 在这种嵌套里解析不稳定(实测 4 个工作区时该值完全没生效,
- * 工作区仍然撑到 483px,日常对话被压成 0 高)。
- */
- /*
- * 工作区最多 26vh(2026-10-30 再次压缩:32 → 26)。
- * 左侧区域要同时容纳品牌/路由/新建/工作区/日常/用户卡,
- * 工作区占太多会把日常对话压得只剩两行。
- */
- max-height: 26vh;
- }
- .sect.grow {
- flex: 1 1 0;
- max-height: none;
- /* 至少给到三行多,保证"这里有内容"是真的看得见 */
- min-height: 140px;
- }
- /*
- * 群聊分组限高(2026-11-06):常驻显示后它和工作区一样按内容撑高,
- * 群一多就会把「日常对话」压成 0 高 —— 日常对话是主场景,必须始终可见。
- */
- .sect--group {
- max-height: 26vh;
- }
- .sect-head {
- flex-shrink: 0;
- }
- /*
- * 分组列表:内部滚动。
- * 底部加一道渐隐遮罩 —— 列表被截断时,用户需要"下面还有"的视觉暗示,
- * 否则会以为工作区里就这几条(实测 feedback:以为内容丢了)。
- */
- .list-limit {
- min-height: 0;
- overflow-y: auto;
- overflow-x: hidden;
- overscroll-behavior: contain;
- }
- /*
- * 只在「确实还有下方内容」时才加渐隐遮罩(由 JS 维护 more-below 类)。
- * 常驻遮罩会把最后一条莫名淡化,看着像被截断。
- */
- .list-limit.more-below {
- mask-image: linear-gradient(to bottom, currentColor calc(100% - 20px), transparent 100%);
- }
- .sect.grow .list-limit {
- flex: 1 1 auto;
- }
- /* 分组标题做成可点击的整行(日常对话折叠用) */
- .sect-toggle {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- flex: 1;
- min-width: 0;
- min-height: 30px;
- padding: 0;
- border: 0;
- background: transparent;
- color: inherit;
- cursor: pointer;
- text-align: left;
- }
- .sect-toggle:hover .sect-name {
- color: var(--ink);
- }
- /*
- * 分组标题的「前置对齐槽」(2026-11-06)
- *
- * 三个分组标题此前各写各的:工作区没有前置元素、群聊是 fa-users 图标、
- * 日常对话是折叠箭头 —— 三者宽度不同,**文字起点参差不齐**(用户实测指出)。
- * 统一给一个固定宽度的槽位,图标/箭头居中放在里面,文字就自然对齐了。
- * 三个分组各配一个语义图标:工作区 fa-layer-group / 群聊 fa-users /
- * 日常对话保留折叠箭头(它是唯一可折叠的分组)。
- */
- .sect-lead {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- flex-shrink: 0;
- font-size: var(--fs-11);
- color: var(--faint);
- }
- /* 当前筛选提示条 */
- .search-chip {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 var(--sp-2) var(--sp-2);
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--r-sm);
- background: var(--primary-subtle);
- color: var(--primary);
- font-size: var(--fs-12);
- }
- .search-chip .ellipsis {
- flex: 1;
- min-width: 0;
- }
- .chip-x {
- display: grid;
- place-items: center;
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: inherit;
- cursor: pointer;
- }
- .chip-x:hover {
- background: var(--primary);
- color: var(--primary-content);
- }
- .sect + .sect {
- margin-top: var(--sp-4);
- }
- .sect-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- min-height: 30px;
- padding: 0 var(--sp-2);
- }
- .sect-name {
- font-size: var(--fs-12);
- font-weight: 500;
- color: var(--muted);
- letter-spacing: 0.2px;
- /* 名字占满剩余空间,「+」被推到最右 */
- flex: 1;
- }
- /* 分组头的「+」**常驻显示**(2026-11-01 用户要求)。 */
- .sect-head ui-button {
- opacity: 1;
- margin-left: auto;
- }
- /*
- * 计数紧跟名字、贴着「+」左侧(2026-11-06 用户要求)。
- *
- * 此前计数紧跟在名字后面(工作区是「工作区 0 +」,0 离名字很远),
- * 群聊的计数更是排在标题文字里,三个分组头的计数位置各不相同。
- * 统一成:名字 ──剩余空间── 计数 +,三个分组一眼对齐。
- */
- .sect-count {
- margin-left: auto;
- font-size: var(--fs-11);
- color: var(--faint);
- font-variant-numeric: tabular-nums;
- }
- /* 名字已有 flex:1 时,计数再用 auto 会把「+」推过头 —— 两者让名字吃空间 */
- .sect-name + .sect-count {
- margin-left: 0;
- }
- .empty-add {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- width: calc(100% - var(--sp-4));
- margin: 0 var(--sp-2) var(--sp-1);
- min-height: 32px;
- padding: 0 var(--sp-2);
- border: 1px dashed var(--edge-strong);
- border-radius: var(--r-md);
- background: transparent;
- color: var(--muted);
- font-size: var(--fs-12);
- text-align: left;
- cursor: pointer;
- }
- .empty-add:hover {
- border-color: var(--primary);
- color: var(--primary);
- }
- .sect-hint {
- margin: var(--sp-1) 0 var(--sp-2);
- padding: 0 var(--sp-3);
- font-size: var(--fs-12);
- color: var(--muted);
- line-height: 1.6;
- }
- .sect-hint.inset {
- padding-left: var(--sp-6);
- }
- .orphan-hint {
- padding-top: var(--sp-2);
- border-top: 1px solid var(--edge);
- }
- /*
- 会话目录加载失败(2026-11-01):这是**错误态**,不是空态。
- 用 danger 语义色边框区分,并提供重试入口 —— 不允许静默展示旧缓存。
- */
- .sect-error {
- margin: var(--sp-1) 0 var(--sp-2);
- padding: var(--sp-3);
- border: 1px solid var(--danger);
- border-radius: var(--r-md);
- background: var(--danger-subtle);
- font-size: var(--fs-12);
- line-height: 1.6;
- color: var(--ink);
- }
- .sect-error p {
- margin: 0;
- }
- .sect-error-detail {
- margin-top: var(--sp-1) !important;
- color: var(--muted);
- word-break: break-all;
- }
- .sect-error .retry {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- min-height: var(--touch-min);
- margin-top: var(--sp-1);
- padding: 0 var(--sp-2);
- border: none;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--primary);
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .sect-error .retry:hover {
- background: var(--surface-hover);
- }
- .sect-error .retry:focus-visible {
- outline: 2px solid var(--primary);
- outline-offset: 2px;
- }
- /* 删除确认框(当前元件在 host 外,样式要挂在模板根上) */
- .rm-note {
- margin: 0 0 var(--sp-4);
- font-size: var(--fs-13);
- line-height: 1.7;
- color: var(--ink-2);
- }
- .rm-acts {
- display: flex;
- justify-content: flex-end;
- gap: var(--sp-2);
- }
- .skel {
- display: grid;
- gap: var(--sp-1);
- padding: var(--sp-1) var(--sp-2);
- }
- /* ---- 工作区行 ---- */
- .ws-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- min-height: 32px;
- padding: 0 var(--sp-2);
- border-radius: var(--r-sm);
- color: var(--ink);
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .ws-head:hover {
- background: var(--surface-hover);
- }
- .caret {
- width: 10px;
- font-size: var(--fs-11);
- color: var(--faint);
- flex-shrink: 0;
- }
- /* 分组标题里的箭头由 .sect-lead 统一控宽,避免双重约束 */
- .sect-lead .caret {
- width: auto;
- }
- .ws-name {
- flex: 1;
- min-width: 0;
- }
- /*
- * 工作区操作图标**常驻显示**(2026-11-01 用户要求)。
- *
- * 原先是 display:none + 悬停/聚焦才出现 —— 用户不知道文件夹上还有这些操作,
- * 只能靠"碰运气"把鼠标移上去。侧栏本来就窄,三颗小图标常驻不会造成拥挤,
- * 换来的是「看得见就能点」。
- */
- .ws-acts {
- display: flex;
- gap: 0;
- flex-shrink: 0;
- }
- .ws-body {
- padding-left: var(--sp-4);
- }
- .ws-edit {
- padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-6);
- }
- .inline-input {
- width: 100%;
- min-height: 28px;
- padding: 0 var(--sp-2);
- border: 1px solid var(--primary);
- border-radius: var(--r-sm);
- background: var(--surface);
- color: var(--ink);
- font-size: var(--fs-13);
- /* 不用 outline:none —— 焦点环由全局 :focus-visible 提供,移除会违反可访问性底线 */
- }
- .inline-input.grow {
- flex: 1;
- min-width: 0;
- }
- /* ---- 会话行 ---- */
- .sess {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- /* 行高 36 + 上下留缝,形成参考图那种"一条条卡片"的观感 */
- min-height: 36px;
- margin-bottom: 2px;
- padding: 0 var(--sp-3);
- border-radius: var(--r-md);
- /*
- * 默认**无背景**(2026-10-30 用户反馈):与侧栏底色一致,只在悬停/选中时给背景。
- * 之前每行都铺 surface 白底,列表看起来像一片卡片墙,反而分不清层级。
- */
- background: transparent;
- color: var(--ink-2);
- font-size: var(--fs-13);
- cursor: pointer;
- transition: background-color var(--motion-fast) var(--ease-standard);
- }
- /* 标题容器:负责裁切与两端渐隐 */
- .sess-agent {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--muted);
- font-size: var(--fs-11);
- line-height: 1.4;
- }
- .sess-title-wrap {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- /* 右侧渐隐:提示"后面还有字"比硬省略号更柔和 */
- mask-image: linear-gradient(to right, currentColor 88%, transparent 100%);
- }
- .sess-title {
- display: inline-block;
- white-space: nowrap;
- transition-property: transform;
- transition-timing-function: linear;
- }
- .sess:hover {
- background: var(--surface-hover);
- color: var(--ink);
- }
- /*
- * 选中态(2026-10-29):从「整块深色底」改为**浅底 + 左侧短线 + 加粗**。
- * 原来一整块 surface-active 蓝灰,在浅色主题里像被高亮标记的文本,
- * 一屏里最抢眼的不是内容而是选中行。
- */
- .sess.active {
- background: var(--surface-hover);
- color: var(--ink);
- font-weight: 600;
- }
- .sess-ico {
- flex-shrink: 0;
- font-size: var(--fs-12);
- color: var(--faint);
- }
- .sess.active .sess-ico {
- color: var(--primary);
- }
- .sess.archived {
- color: var(--muted);
- }
- .sess-title {
- flex: 1;
- min-width: 0;
- }
- /*
- * 会话行(2026-10-31):
- * · 操作图标常驻,默认低透明度,悬停/聚焦/选中恢复全亮
- * · 不显示时间 —— 用户澄清「时间」指聊天区每条消息,不是左侧列表
- */
- .sess-time {
- display: none;
- }
- .sess-acts {
- display: flex;
- flex-shrink: 0;
- opacity: 0.55;
- transition: opacity var(--motion-fast) var(--ease-standard);
- }
- .sess:hover .sess-acts,
- .sess:focus-within .sess-acts,
- .sess.active .sess-acts {
- opacity: 1;
- }
- .arch-toggle {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- width: 100%;
- min-height: 28px;
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--muted);
- font-size: var(--fs-12);
- text-align: left;
- cursor: pointer;
- }
- .arch-toggle:hover {
- background: var(--surface-hover);
- color: var(--ink-2);
- }
- .arch-count {
- font-size: var(--fs-11);
- }
- /* ---- 底部用户卡 ---- */
- /*
- * 底部账号区(2026-10-30 对照 WorkBuddy 参考图重做):
- * 原来是「白色卡片 + 上边框」贴在灰色侧栏底部,像一块补丁。
- * 参考图的账号区是**同底色、无边框**的一行,只在悬停时给浅底 —— 视觉上属于侧栏的一部分。
- */
- .user-card {
- position: relative;
- flex-shrink: 0;
- padding: var(--sp-2);
- background: transparent;
- }
- .user-main {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: var(--touch-min);
- padding: var(--sp-2) var(--sp-2);
- border: 0;
- border-radius: var(--r-md);
- background: transparent;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
- }
- /* 头像本体 32px,外层按钮必须留出余量,否则被边框指标裁掉 */
- .user-main ui-avatar {
- flex-shrink: 0;
- display: block;
- }
- /* 悬停时才出现的「⋯」,常驻会让整行显得拥挤 */
- .user-main > .dots {
- color: var(--muted);
- opacity: 0;
- transition: opacity var(--motion-fast) var(--ease-standard);
- }
- /*
- * 用户卡片:悬停反馈 + 箭头方向(2026-11-01 用户要求)。
- *
- * · 默认箭头**朝上**(▲)=「点我向上展开菜单」
- * · 悬停 / 菜单已展开 → 箭头**朝下**(▼)
- * · 整行背景随悬停变浅,明确「这一整条都是可点的」
- */
- .user-main:hover {
- background: var(--surface-hover);
- }
- .user-main:active {
- background: var(--surface-active);
- }
- .user-main:hover > .dots,
- .user-main:focus-visible > .dots {
- opacity: 1;
- }
- /*
- * 悬停就把箭头翻过来(原来是 .rot 只在菜单展开时加)。
- * 用 [aria-expanded='true'] 覆盖:菜单已展开时保持朝下,
- * 否则鼠标移开又翻回朝上,与「菜单开着」的状态矛盾。
- */
- .user-main:hover > .dots,
- .user-main:focus-visible > .dots,
- .user-main[aria-expanded='true'] > .dots {
- transform: rotate(180deg);
- }
- .dots {
- transition: opacity var(--motion-fast) var(--ease-standard),
- transform var(--motion-fast) var(--ease-standard);
- }
- /* 头像 + 在线状态点 */
- .user-avatar {
- position: relative;
- flex-shrink: 0;
- display: block;
- }
- .user-dot {
- position: absolute;
- right: -1px;
- bottom: -1px;
- width: 9px;
- height: 9px;
- border-radius: 50%;
- background: var(--success);
- box-shadow: 0 0 0 2px var(--canvas);
- }
- .user-dot.off {
- background: var(--faint);
- }
- .user-role {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .user-main:hover {
- background: var(--surface-hover);
- }
- .user-main:active {
- background: var(--surface-active);
- }
- .user-text {
- display: grid;
- flex: 1;
- min-width: 0;
- line-height: 1.3;
- }
- .user-name {
- font-size: var(--fs-13);
- font-weight: 600;
- color: var(--ink);
- }
- .user-sub {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .menu-backdrop {
- position: fixed;
- inset: 0;
- z-index: var(--z-popover);
- }
- .user-menu {
- position: absolute;
- left: var(--sp-2);
- right: var(--sp-2);
- bottom: calc(100% + var(--sp-1));
- z-index: calc(var(--z-popover) + 1);
- max-height: 60vh;
- overflow-y: auto;
- padding: var(--sp-1);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- box-shadow: var(--shadow-pop);
- }
- .move-backdrop {
- position: fixed;
- inset: 0;
- z-index: var(--z-popover);
- }
- .move-menu {
- position: fixed;
- z-index: calc(var(--z-popover) + 1);
- min-width: 180px;
- max-height: 60vh;
- overflow-y: auto;
- padding: var(--sp-1);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- box-shadow: var(--shadow-pop);
- }
- .menu-label {
- margin: var(--sp-1) 0 0;
- padding: var(--sp-1) var(--sp-2);
- font-size: var(--fs-11);
- color: var(--faint);
- }
- /* 带控件的菜单行(开关 / 色块):不可点击,控件自己负责交互 */
- /*
- * 深色模式行(2026-10-30 调整):
- * 原来开关比文字还抢眼(ui-switch md 尺寸 + 行高 36)。
- * 现在行高 34、开关用 sm,让「深色模式」这几个字是主体、开关是配角。
- */
- .menu-row {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- min-height: 34px;
- padding: 0 var(--sp-2);
- color: var(--ink);
- font-size: var(--fs-13);
- }
- .menu-row > i {
- width: 14px;
- text-align: center;
- color: var(--muted);
- }
- .menu-row-text {
- flex: 1;
- min-width: 0;
- }
- .menu-item {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- width: 100%;
- min-height: 32px;
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--ink);
- font-size: var(--fs-13);
- text-align: left;
- cursor: pointer;
- }
- .menu-item:hover {
- background: var(--surface-hover);
- }
- .menu-item[aria-checked='true'] {
- color: var(--primary);
- font-weight: 600;
- }
- /* ---- 配色项:色块 + 名称 ---- */
- .swatch-item {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 34px;
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--ink);
- font-size: var(--fs-13);
- text-align: left;
- cursor: pointer;
- }
- .swatch-item:hover {
- background: var(--surface-hover);
- }
- .swatch-item[aria-checked='true'] {
- font-weight: 600;
- }
- /*
- * 色块(2026-10-30):**不写任何色值**。
- * data-preview-palette 作用域里,_palette.scss 已经把该配色的整套令牌
- * (--nav-surface / --primary 等)覆盖成那套的真实取值 ——
- * 直接引用它们,色块天然跟随配色定义,配色调整时不用改这里。
- */
- .swatch {
- display: grid;
- place-items: center;
- /* 20×20:与文字行高的视觉重量匹配,22 在小字号旁边显得头重 */
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- border-radius: var(--r-sm);
- border: 1px solid var(--edge);
- background: var(--canvas);
- overflow: hidden;
- }
- .swatch-in {
- display: block;
- width: 100%;
- height: 100%;
- background: linear-gradient(135deg, var(--nav-surface) 50%, var(--primary) 50%);
- }
- .swatch-name {
- flex: 1;
- min-width: 0;
- }
- .swatch-check {
- color: var(--primary);
- font-size: var(--fs-12);
- }
- .menu-item > i {
- width: 14px;
- text-align: center;
- }
- .menu-line {
- margin: var(--sp-1) 0;
- border: 0;
- border-top: 1px solid var(--edge);
- }
- .ellipsis {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- `,
- ],
- })
- export class SessionTreeComponent {
- private readonly sessionsSvc = inject(ChatSessionService);
- private readonly workspaceSvc = inject(WorkspaceService);
- private readonly navMenu = inject(NavMenuService);
- private readonly chatFont = inject(ChatFontService);
- /** 自定义菜单栏弹窗开关 */
- protected readonly navCustom = inject(NavCustomModalService);
- /** 新建对话弹窗(群聊分组头的「+」直达群聊标签) */
- private readonly newSessionModal = inject(NewSessionModalService);
- private readonly liveSvc = inject(LiveService);
- private readonly unreadSvc = inject(UnreadService);
- private readonly session = inject(SessionService);
- private readonly router = inject(Router);
- private readonly device = inject(DeviceService);
- /** 与 chat 页共用同一实例:打开会话走同一套 URL 规则 */
- protected readonly chat = inject(ChatService);
- protected readonly theme = inject(ThemeService);
- protected readonly palette = inject(PaletteService);
- protected readonly palettes = PALETTES;
- protected readonly live = this.liveSvc;
- /**
- * 当前打开的会话 id(用于高亮)。
- *
- * 🔴 左栏提升到 shell 后,页面不再传这个值 ——
- * 改由组件**自己从 URL 解析**(路由形如 `/desktop/chat/:agentId/:sessionId`)。
- * 保留 input 只为兼容可能的显式覆盖(不传时为 null,走 URL)。
- */
- readonly activeSessionId = input<string | null>(null);
- /** 实际高亮的会话:显式传入优先,否则取 URL 末段 */
- protected readonly activeId = computed(() => {
- const explicit = this.activeSessionId();
- if (explicit) return explicit;
- return activeSessionIdOf(this.url());
- });
- /**
- * 请求新建对话 → 由外壳弹「选容器」模态框。
- *
- * 2026-10-30:新建入口收敛为**顶部唯一一个按钮**,所以不再区分"在哪个工作区下新建" ——
- * 工作区归属由用户点完之后在会话行上「移动到工作区」决定,或者直接进日常对话。
- */
- readonly newSession = output<string | null>();
- /** 请求收起左侧栏(由外壳控制骨架列宽) */
- readonly collapse = output<void>();
- /*
- * 一级路由(原 `dk-nav` 顶部导航项,2026-10-30 整体挪进左栏)。
- * 点击只换右侧内容区,左栏保持不动。
- */
- /*
- * 一级菜单来自 NavMenuService(用户可在「更多 → 自定义菜单栏」里排序/隐藏)。
- *
- * 直接展示前 4 项,其余进「更多」。4 是按当前左栏高度定的经验值:
- * 再多会把会话区挤得太短。
- */
- private static readonly PRIMARY_COUNT = 4;
- protected readonly primaryNav = computed(() =>
- this.navMenu.visible().slice(0, SessionTreeComponent.PRIMARY_COUNT),
- );
- protected readonly moreNav = computed(() =>
- this.navMenu.visible().slice(SessionTreeComponent.PRIMARY_COUNT),
- );
- /** 「更多」菜单是否展开 */
- protected readonly moreOpen = signal(false);
- /** 浮层位置(跟随「更多」按钮) */
- protected readonly moreMenuPos = signal({ x: 0, y: 0 });
- /**
- * 展开/收起「更多」。
- *
- * 位置按按钮实时矩形计算:贴在按钮右侧、与按钮上沿对齐;
- * 下方空间不足时向上翻,避免被视口底部截断。
- */
- protected toggleMore(e: Event): void {
- if (this.moreOpen()) {
- this.moreOpen.set(false);
- return;
- }
- const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
- const menuH = 12 + this.moreNav().length * 32 + 40;
- const y = r.bottom + menuH > window.innerHeight ? Math.max(8, r.top - menuH) : r.top;
- this.moreMenuPos.set({ x: r.right + 8, y });
- this.moreOpen.set(true);
- }
- /** 「更多」里是否有当前页(决定按钮是否高亮) */
- /** 「更多」里是否有当前页(决定按钮是否高亮) */
- protected readonly moreActive = computed(() =>
- this.moreNav().some((i) => !!i.path && this.isActive(i.path)),
- );
- /** 当前 URL(随 NavigationEnd 更新;OnPush 下必须用 signal) */
- private readonly _url = signal(this.router.url);
- private readonly url = this._url.asReadonly();
- protected isActive(path: string): boolean {
- return navItemActive(this.url(), this.device.mode(), path);
- }
- protected link(path: string): string {
- return `/${this.device.mode()}${path}`;
- }
- /** 会话项显示未读总数 */
- protected badgeFor(path: string): number {
- if (path !== '/chat') return 0;
- return this.liveSvc.agents().reduce((n, a) => n + (a.unread ?? 0), 0);
- }
- protected readonly keyword = signal('');
- protected readonly userMenuOpen = signal(false);
- /** 日常对话是否展开(默认展开,可折叠) */
- protected readonly dailyOpen = signal(true);
- /** 分组列表是否还有下方内容(用于渐隐遮罩,提示"还能滚") */
- protected readonly wsOverflow = signal(false);
- protected readonly dailyOverflow = signal(false);
- protected readonly groupOverflow = signal(false);
- /**
- * 重新测量各分组列表是否溢出。
- *
- * 用 queueMicrotask 延后一帧:数据变化后 DOM 还没重排,立即测会拿到旧高度。
- * 按分组类名取,不再按下标 —— 群聊分组常驻后下标对不上(2026-11-06)。
- */
- private measureOverflow(): void {
- queueMicrotask(() => {
- const over = (sel: string): boolean => {
- const el = document.querySelector(sel);
- return !!el && el.scrollHeight > el.clientHeight + 2;
- };
- this.wsOverflow.set(over('.sect:not(.sect--group) .list-limit'));
- this.groupOverflow.set(over('.sect--group .list-limit'));
- this.dailyOverflow.set(over('.sect.grow .list-limit'));
- });
- }
- private readonly prompt = inject(PromptModalService);
- // ---- 工作区(第 3 轮:接入 WorkspaceService 持久化) ----
- /** 工作区列表来自 service(localStorage 持久化,按用户隔离) */
- protected readonly workspaces = computed(() => this.workspaceSvc.sorted());
- /** 纯 UI 状态:哪些工作区是展开的(不持久化,刷新后默认全部展开) */
- private readonly closedWs = signal<Set<string>>(new Set());
- /** 展开的「已归档」分组(工作区 id 或 `__daily__`) */
- protected readonly archOpen = signal<Set<string>>(new Set());
- constructor() {
- /*
- * 从 localStorage 载入工作区。
- * 🔴 漏掉这一行的症状:能新建、能显示,但**一刷新全没了**,
- * 而且 localStorage 里根本没有对应键(create 时 key() 有值却没人 load 过)。
- */
- this.workspaceSvc.load();
- this.navMenu.load();
- // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
- this.chatFont.load();
- /*
- * 会话目录预热(2026-11-01)。
- *
- * 🔴 云端是唯一来源,左栏要列出**所有容器**的对话,就必须逐个容器去拉;
- * 只在进入某容器时拉,用户没进过的容器会一直显示「没有对话」(看起来像丢了)。
- * 拉取失败时 `ready=false`,左栏显示「加载失败 + 重试」,不展示任何缓存。
- */
- effect(() => {
- // 只在登录后、且容器清单首次就绪时拉一轮(LiveService 每 15s 刷新会触发 new 数组)
- if (!this.session.isLoggedIn()) return;
- const ids = this.liveSvc
- .agents()
- .filter((a) => a.matrixReady)
- .map((a) => a.id);
- if (ids.length === 0) return;
- const sig = ids.join(',');
- if (sig === this.loadedAgentsSig) return;
- this.loadedAgentsSig = sig;
- void this.sessionsSvc.reloadAll(ids);
- });
- // 数据/尺寸变化后重测溢出(工作区与会话列表都是信号,变化即重算)
- effect(() => {
- this.workspaces();
- this.sessionsSvc.all();
- this.dailyOpen();
- this.measureOverflow();
- });
- if (typeof window !== 'undefined') {
- window.addEventListener('resize', () => this.measureOverflow());
- }
- this.router.events.subscribe((e) => {
- if (e instanceof NavigationEnd) this._url.set(e.urlAfterRedirects);
- });
- this._url.set(this.router.url);
- }
- /**
- * 会话所属容器名(2026-11-04)。
- *
- * 左侧列表只显示会话标题时,用户看不出这条对话是跟哪个数字生命聊的 ——
- * 同一容器下常有多条会话,标题相同或相似时尤其容易混淆。
- * 取不到容器时显示占位,不编造。
- */
- protected agentNameOf(s: ChatSession): string {
- return this.liveSvc.agentById(s.agentId)?.name ?? '未知容器';
- }
- /**
- * 群聊天行第二行(2026-11-06)。
- *
- * 群会话的 `agentId` 只是「首成员」,照单聊口径显示容器名会误导 ——
- * 「见微、栖云」底下再写「见微」,用户看不出这是个群。改为成员数。
- */
- protected groupSubline(s: ChatSession): string {
- const n = s.memberIds?.length ?? 0;
- return n > 0 ? `${n} 个容器` : '群聊';
- }
- /** 已经预热过会话目录的容器清单签名(避免 LiveService 轮询触发重复拉取) */
- private loadedAgentsSig = '';
- protected readonly meName = computed(() => this.session.me()?.name ?? '未登录');
- /** 账号辅助行:不是重复主题名,而是「你在这个平台上的身份」 */
- protected readonly userRole = computed(() => (this.session.me() ? '个人账号' : '未登录'));
- /**
- * 全部会话,按归属拆分。
- *
- * 第 1 轮没有 `workspaceId`,所以**所有会话都落在日常对话**——
- * 这是数据层的真实状态,不是视觉降级;第 3 轮接上归属后本方法只改 `ownerOf` 一处。
- */
- /** 群会话(2026-11-05) */
- protected readonly groupSessions = computed(() =>
- this.filter(this.sessionsSvc.all().filter((s) => s.kind === 'group' && !s.archived)),
- );
- /** 日常对话:非群聊、无工作区归属 */
- protected readonly daily = computed(() =>
- this.filter(this.activeList(null)).filter((s) => s.kind !== 'group'),
- );
- /** 会话目录是否可信(false = 云端拉取失败,列表不可展示) */
- protected readonly sessionsReady = this.sessionsSvc.ready;
- protected readonly sessionsError = this.sessionsSvc.error;
- /**
- * 重试拉取:按当前打开的容器再拉一次;没有打开任何容器时拉全部容器的列表。
- * 失败仍维持「不可展示」状态,不做任何本地兜底。
- */
- protected retrySessions(): void {
- const agentId = this.chat.agentId();
- if (agentId) {
- void this.chat.open(agentId, this.chat.sessionId() ?? undefined);
- return;
- }
- void this.sessionsSvc.reloadAll(
- this.liveSvc
- .agents()
- .filter((a) => a.matrixReady)
- .map((a) => a.id),
- );
- }
- protected readonly dailyArchived = computed(() => this.filter(this.archivedList(null)));
- protected sessionsIn(wsId: string): ChatSession[] {
- return this.filter(this.activeList(wsId));
- }
- protected archivedIn(wsId: string): ChatSession[] {
- return this.filter(this.archivedList(wsId));
- }
- protected sessionTime(ms: number): string {
- const d = new Date(ms);
- const now = new Date();
- const sameDay =
- d.getFullYear() === now.getFullYear() &&
- d.getMonth() === now.getMonth() &&
- d.getDate() === now.getDate();
- const hh = String(d.getHours()).padStart(2, '0');
- const mm = String(d.getMinutes()).padStart(2, '0');
- return sameDay ? `${hh}:${mm}` : `${d.getMonth() + 1}-${d.getDate()}`;
- }
- // ---- 删除会话 ----
- /** 待删除的会话(非 null = 确认框打开) */
- protected readonly removing = signal<ChatSession | null>(null);
- /** 点删除图标 → 先弹确认(删除不可恢复) */
- protected askRemove(s: ChatSession, e: Event): void {
- e.stopPropagation();
- this.removing.set(s);
- }
- /** 确认删除:移除索引 + 云端物理删 + 留墓碑(Matrix 消息不动) */
- protected doRemove(): void {
- const s = this.removing();
- this.removing.set(null);
- if (!s) return;
- this.sessionsSvc.remove(s.id);
- /*
- * 删的正好是当前打开的那条 → 打开该容器下一条,避免停在空白页。
- * 一条都不剩时回到 `/chat/:agentId`(该容器空态)。
- */
- const wasOpen = this._url().includes(`/chat/${s.agentId}/${encodeURIComponent(s.id)}`);
- if (wasOpen) {
- const next = this.sessionsSvc.listFor(s.agentId)[0];
- if (next) this.openSession(next);
- else void this.router.navigateByUrl(`/${this.device.mode()}/chat/${s.agentId}`);
- }
- }
- // ---- 工作区 CRUD ----
- /** 新建工作区 → 输入模态框 */
- protected beginNewWorkspace(): void {
- this.prompt.open({
- title: '新建工作区',
- placeholder: '给工作区起个名字,如「客户端 v2」',
- value: '',
- confirmText: '创建',
- allowEmpty: false,
- /*
- * 🔴 重名校验放在提交前(2026-11-01 用户要求)。
- * 两个同名文件夹在侧栏里完全无法区分,用户点了也不知道进了哪个。
- * 校验失败时弹窗不关闭、当场提示,而不是静默失效。
- */
- validate: (name) =>
- this.workspaceSvc.hasName(name) ? `已经有叫「${name.trim()}」的工作区了` : null,
- onConfirm: (name) => this.workspaceSvc.create(name),
- });
- }
- /** 搜索对话 → 输入模态框(输入即筛选,清空即取消筛选) */
- /**
- * 在指定工作区下新建对话 → 交给外壳弹「选容器」模态框,并带上目标工作区。
- *
- * 2026-10-30 用户反馈:工作区建好后没有任何新建入口(上一轮把分组里的 + 全删了,
- * 结果工作区只能等会话"移进来",无法直接创建)。
- */
- protected newSessionIn(workspaceId: string, e: Event): void {
- e.stopPropagation();
- this.newSession.emit(workspaceId);
- }
- protected openSearch(): void {
- this.prompt.open({
- title: '搜索对话',
- placeholder: '输入对话标题或容器名',
- value: this.keyword(),
- confirmText: '搜索',
- allowEmpty: true,
- onConfirm: (kw) => this.keyword.set(kw.trim()),
- });
- }
- /** 工作区是否展开(默认展开;折叠状态只存内存,不写盘) */
- protected isWsOpen(id: string): boolean {
- return !this.closedWs().has(id);
- }
- protected toggleWorkspace(id: string): void {
- this.closedWs.update((set) => {
- const next = new Set(set);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- }
- protected beginRenameWorkspace(w: Workspace, e: Event): void {
- e.stopPropagation();
- this.prompt.open({
- title: '重命名工作区',
- placeholder: '工作区名称',
- value: w.name,
- confirmText: '保存',
- allowEmpty: false,
- // 重命名同样查重(排除自己,否则「不改名直接保存」会被误判)
- validate: (name) =>
- this.workspaceSvc.hasName(name, w.id) ? `已经有叫「${name.trim()}」的工作区了` : null,
- onConfirm: (name) => this.workspaceSvc.rename(w.id, name),
- });
- }
- /**
- * 删除工作区。
- *
- * 🔴 **不删其中的会话** —— 把会话退回「日常对话」。
- * 连带删除会让用户看不到的东西被静默销毁,这是数据丢失,不是"整理"。
- */
- /**
- * 删除工作区 —— **二次确认**(2026-11-01 用户要求)。
- *
- * 删除工作区不是纯 UI 操作:它下面若有对话,会被移回「日常对话」。
- * 不说清楚就直接删,用户会以为对话一起没了。
- */
- protected deleteWorkspace(id: string, e: Event): void {
- e.stopPropagation();
- const w = this.workspaceSvc.byId(id);
- if (w) this.removingWorkspace.set(w);
- }
- /** 待删除的工作区(非 null = 确认框打开) */
- protected readonly removingWorkspace = signal<Workspace | null>(null);
- /** 确认删除工作区:其下会话退回日常对话 */
- protected doDeleteWorkspace(): void {
- const w = this.removingWorkspace();
- this.removingWorkspace.set(null);
- if (!w) return;
- for (const s of this.sessionsSvc.all().filter((x) => x.workspaceId === w.id)) {
- this.sessionsSvc.moveTo(s.id, null);
- }
- this.workspaceSvc.remove(w.id);
- }
- /** 该工作区下的会话数(确认框里说明用) */
- protected sessionCountIn(wsId: string): number {
- return this.sessionsSvc.all().filter((s) => s.workspaceId === wsId).length;
- }
- // ---- 会话操作 ----
- /** 正在挑选移动目标的会话(null = 菜单关闭) */
- protected readonly movingSession = signal<ChatSession | null>(null);
- /** 菜单位置:跟随触发按钮,避免跑到屏幕中央 */
- protected readonly moveMenuPos = signal({ x: 0, y: 0 });
- protected openMoveMenu(s: ChatSession, e: Event): void {
- e.stopPropagation();
- const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
- /* 右对齐到按钮,向下展开;靠近视口底部时向上翻(避免被裁) */
- const menuH = 60 + this.workspaces().length * 34;
- const y = r.bottom + menuH > window.innerHeight ? Math.max(8, r.top - menuH) : r.bottom + 4;
- this.moveMenuPos.set({ x: Math.max(8, r.right - 180), y });
- this.movingSession.set(s);
- }
- protected moveSession(s: ChatSession, wsId: string | null): void {
- this.sessionsSvc.moveTo(s.id, wsId);
- this.movingSession.set(null);
- }
- /** 打开会话:群聊走独立群聊页,单聊走会话页(2026-11-05) */
- protected openSession(s: ChatSession): void {
- void this.router.navigateByUrl(
- s.kind === 'group'
- ? `/${this.device.mode()}/group/${encodeURIComponent(s.id)}`
- : `/${this.device.mode()}/chat/${s.agentId}/${encodeURIComponent(s.id)}`,
- );
- }
- protected toggleArchive(s: ChatSession, e: Event): void {
- e.stopPropagation();
- /*
- * 归档也要二次确认(2026-11-01 用户要求)。
- * 取消归档不需要确认 —— 它是**恢复**操作,误点不会造成损失。
- */
- if (s.archived) {
- this.sessionsSvc.archive(s.id, false);
- return;
- }
- this.archiving.set(s);
- }
- /** 待确认归档的会话(非 null = 确认框打开) */
- protected readonly archiving = signal<ChatSession | null>(null);
- /** 确认归档 */
- protected doArchive(): void {
- const s = this.archiving();
- this.archiving.set(null);
- if (!s) return;
- this.sessionsSvc.archive(s.id, true);
- // 归档后自动展开该分组的「已归档」——让用户看到对话去了哪里
- const owner = this.ownerOf(s);
- this.archOpen.update((set) => new Set(set).add(owner ?? '__daily__'));
- }
- protected isArchOpen(owner: string): boolean {
- return this.archOpen().has(owner);
- }
- protected toggleArchiveGroup(owner: string): void {
- this.archOpen.update((set) => {
- const next = new Set(set);
- if (next.has(owner)) next.delete(owner);
- else next.add(owner);
- return next;
- });
- }
- // ---- 用户卡菜单 ----
- protected toggleDark(): void {
- this.theme.toggleMode();
- }
- protected goSettings(): void {
- this.userMenuOpen.set(false);
- void this.router.navigateByUrl(`/${this.device.mode()}/mine`);
- }
- // ---- 内部 ----
- /**
- * 会话归属的工作区 id。
- *
- * 若归属的工作区已被删除(其它标签页删的),按「日常对话」处理 ——
- * 否则会话会从界面上彻底消失(悬空引用)。
- */
- /**
- * 会话归属哪个分组。
- *
- * 🔴 2026-11-01:归属**以会话自身的 workspaceId 为准**,
- * 不再因为「工作区列表里暂时没有这个 id」就把会话改判到日常对话。
- *
- * 原实现 `return this.workspaceSvc.byId(id) ? id : null` 的问题是加载时序:
- * 工作区列表比会话到达得晚(或还没 load 完)时,会话会被静默归到日常,
- * 用户看到的就是「在工作区里新建对话,结果跑到下面日常里去了」。
- *
- * 孤儿归属(工作区已被删除)由 `orphanSessions` 单独兜底展示,
- * 不让它在渲染层消失。
- */
- private ownerOf(s: ChatSession): string | null {
- return s.workspaceId ?? null;
- }
- /**
- * 归属指向了不存在工作区的会话(孤儿)。
- *
- * 正常不应出现:删除工作区时会把其下会话退回日常(`WorkspaceService.remove`)。
- * 但历史数据 / 云端归属与本地工作区不同步时会碰到 —— 这些会话**必须可见**,
- * 否则用户会以为对话丢了。
- */
- protected readonly orphanSessions = computed(() => {
- const known = new Set(this.workspaces().map((w) => w.id));
- return this.sessionsSvc
- .all()
- .filter((s) => !s.archived && !!s.workspaceId && !known.has(s.workspaceId));
- });
- /** 某归属下的活跃会话(按更新时间倒序由 service 保证) */
- private activeList(owner: string | null): ChatSession[] {
- return this.sessionsSvc.all().filter((s) => !s.archived && this.ownerOf(s) === owner);
- }
- private archivedList(owner: string | null): ChatSession[] {
- return this.sessionsSvc.all().filter((s) => s.archived && this.ownerOf(s) === owner);
- }
- /**
- * 群聊分组头的「+」:打开「新建对话」并直接停在群聊标签(2026-11-06 用户裁决)。
- * 群聊不归属工作区,目标归属传 null。
- */
- protected beginNewGroup(): void {
- this.newSessionModal.open(null, 'group');
- }
- /** 关键字过滤(标题或容器名命中) */
- private filter(list: ChatSession[]): ChatSession[] {
- const q = this.keyword().trim().toLowerCase();
- if (!q) return list;
- return list.filter((s) => {
- if (s.title.toLowerCase().includes(q)) return true;
- const agent = this.liveSvc.agentById(s.agentId);
- return (agent?.name ?? '').toLowerCase().includes(q);
- });
- }
- /** 容器在线色调(供后续行内状态点使用;保留以免第 3 轮重写) */
- protected toneOf(agentId: string): string {
- return agentStatusTone(this.liveSvc.agentById(agentId)?.status as never);
- }
- protected unreadOf(agentId: string): number {
- return this.unreadSvc.countOf(agentId);
- }
- }
|