| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391 |
- 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, groupOf } 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';
- import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
- /** 工作区(用户手动新建的文件夹) */
- /** 一次「选择容器并新建对话」的请求(由页面弹模态框处理) */
- 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 }"
- />
- }
- <!--
- 群聊的「已归档」(2026-11-08 用户反馈):归档是「收起来」,不是「搬走」。
- 原实现把群聊归档塞进「日常对话 → 已归档」,用户找不到。
- -->
- @if (groupArchived().length > 0) {
- <button
- type="button"
- class="arch-toggle"
- [attr.aria-expanded]="isArchOpen('__group__')"
- (click)="toggleArchiveGroup('__group__')"
- >
- <i
- class="fa-solid"
- [class.fa-chevron-right]="!isArchOpen('__group__')"
- [class.fa-chevron-down]="isArchOpen('__group__')"
- aria-hidden="true"
- ></i>
- 已归档 <span class="arch-count">{{ groupArchived().length }}</span>
- </button>
- @if (isArchOpen('__group__')) {
- @for (s of groupArchived(); 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)">
- <!--
- 容器状态点(2026-11-08 用户要求):
- 与移动端会话列表、新建弹窗同一口径 resolveAgentState ——
- 判心跳、防上报器自指。原先左栏完全不显示状态(toneOf 是死代码)。
- 群聊不挂单容器,不显示点。
- -->
- @if (s.kind !== 'group') {
- <i
- class="sess-dot"
- [attr.data-tone]="agentStateOf(s).tone"
- [attr.title]="agentStateOf(s).text"
- [attr.aria-label]="agentStateOf(s).text"
- aria-hidden="true"
- ></i>
- }
- {{ 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;
- }
- /*
- * 容器状态点(2026-11-08):6px,跟在容器名前面。
- * 颜色只是加强 —— 文案在 title / aria-label 里,悬停可读。
- */
- .sess-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- margin-right: 4px;
- border-radius: 50%;
- vertical-align: middle;
- background: var(--faint);
- }
- .sess-dot[data-tone='success'] {
- background: var(--success);
- }
- .sess-dot[data-tone='warning'] {
- background: var(--warning);
- }
- .sess-dot[data-tone='danger'] {
- background: var(--danger);
- }
- .sess-dot[data-tone='info'] {
- background: var(--primary);
- }
- .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.sessionsSvc.all().filter((s) => s.archived && groupOf(s).kind === 'daily'),
- ),
- );
- /** 群聊的已归档(2026-11-08:归档回到群聊分组,不再掉进「日常对话」) */
- protected readonly groupArchived = computed(() =>
- this.filter(
- this.sessionsSvc.all().filter((s) => s.archived && groupOf(s).kind === 'group'),
- ),
- );
- /**
- * 某工作区的活跃会话。
- * 🔴 必须排除群聊:群聊恒不归属工作区,但历史/异常数据里 `workspaceId` 可能被写上,
- * 不排除会出现「同一群聊同时在群聊和工作区两边」。
- */
- protected sessionsIn(wsId: string): ChatSession[] {
- return this.filter(
- this.sessionsSvc
- .all()
- .filter((s) => !s.archived && groupOf(s).kind === 'workspace' && s.workspaceId === wsId),
- );
- }
- protected archivedIn(wsId: string): ChatSession[] {
- return this.filter(
- this.sessionsSvc
- .all()
- .filter((s) => s.archived && groupOf(s).kind === 'workspace' && s.workspaceId === 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);
- /*
- * 归档后自动展开**该会话原分组**的「已归档」——让用户看到对话去了哪里。
- * 🔴 键必须与模板一致(2026-11-08):群聊用 `__group__`,工作区用工作区 id,
- * 日常用 `__daily__`。原实现统一用 `owner ?? '__daily__'`,
- * 群聊(workspaceId 为 null)于是展开了日常分组 —— 用户看到归档跑到日常里。
- */
- const g = groupOf(s);
- const key = g.kind === 'workspace' ? g.id : g.kind === 'group' ? '__group__' : '__daily__';
- this.archOpen.update((set) => new Set(set).add(key));
- }
- 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);
- }
- /**
- * 群聊分组头的「+」:打开「新建对话」并直接停在群聊标签(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);
- }
- /**
- * 会话所属容器的状态(2026-11-08)。
- *
- * 用 `resolveAgentState` 而不是裸 `status`:后者不看心跳,
- * 45 天没心跳的僵尸容器会被涂成绿色「正在执行」。
- * 与移动端会话列表、新建弹窗同一口径。
- */
- protected agentStateOf(s: ChatSession): AgentStateView {
- return resolveAgentState({ agent: this.liveSvc.agentById(s.agentId) });
- }
- protected unreadOf(agentId: string): number {
- return this.unreadSvc.countOf(agentId);
- }
- }
|