|
@@ -180,14 +180,23 @@ interface ConversationRow {
|
|
|
原来它是一条横跨整列、带底色和左竖线的横条 —— 比标题还抢眼,
|
|
原来它是一条横跨整列、带底色和左竖线的横条 —— 比标题还抢眼,
|
|
|
而且把消息流整体往下推。参考 ChatGPT:状态属于页头,不属于内容。
|
|
而且把消息流整体往下推。参考 ChatGPT:状态属于页头,不属于内容。
|
|
|
-->
|
|
-->
|
|
|
- <span
|
|
|
|
|
- class="task-line"
|
|
|
|
|
- [class]="'tone-' + taskLine(a.id).tone"
|
|
|
|
|
- [class.busy]="taskLine(a.id).active"
|
|
|
|
|
- [title]="taskLine(a.id).text"
|
|
|
|
|
- >
|
|
|
|
|
- <span class="spin" aria-hidden="true"></span>
|
|
|
|
|
- <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
|
|
|
|
|
|
|
+ <!--
|
|
|
|
|
+ 任务状态固定占位(2026-10-30):
|
|
|
|
|
+ 原来「无任务」时这个元素不存在、有任务时突然出现,
|
|
|
|
|
+ 把标题栏撑高 → 下方消息区整体下移一截,观感就是"发送后界面跳一下"。
|
|
|
|
|
+ 现在用固定宽高的槽位承载,文字在槽内截断。
|
|
|
|
|
+ -->
|
|
|
|
|
+ <span class="task-slot" [title]="taskLine(a.id).text">
|
|
|
|
|
+ @if (taskLine(a.id).text) {
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="task-line"
|
|
|
|
|
+ [class]="'tone-' + taskLine(a.id).tone"
|
|
|
|
|
+ [class.busy]="taskLine(a.id).active"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="spin" aria-hidden="true"></span>
|
|
|
|
|
+ <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ }
|
|
|
</span>
|
|
</span>
|
|
|
}
|
|
}
|
|
|
<div class="chat-actions">
|
|
<div class="chat-actions">
|
|
@@ -355,10 +364,11 @@ interface ConversationRow {
|
|
|
/>
|
|
/>
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
|
|
|
|
|
- @if (chat.peerTyping()) {
|
|
|
|
|
- <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ <!--
|
|
|
|
|
+ 对方正在输入:已移到消息流**下方固定槽位**(见 .typing-hold)。
|
|
|
|
|
+ 放在流里会把内容顶高一截(出现/消失各跳一次),
|
|
|
|
|
+ 而且它出现在视口外时用户根本看不到(截图实测)。
|
|
|
|
|
+ -->
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
</div>
|
|
</div>
|
|
@@ -367,6 +377,18 @@ interface ConversationRow {
|
|
|
输入区外层(2026-10-29):与消息流同宽居中限宽 768px,
|
|
输入区外层(2026-10-29):与消息流同宽居中限宽 768px,
|
|
|
卡片式输入框才不会被拉到整列宽度(宽屏下单行输入框横跨 1200px 很怪)。
|
|
卡片式输入框才不会被拉到整列宽度(宽屏下单行输入框横跨 1200px 很怪)。
|
|
|
-->
|
|
-->
|
|
|
|
|
+ <!--
|
|
|
|
|
+ 正在输入槽位(2026-10-30):固定高度、不参与消息流布局。
|
|
|
|
|
+ 有内容时显示指示器,没有时留空 —— 出现/消失不改变任何高度。
|
|
|
|
|
+ -->
|
|
|
|
|
+ @if (chat.agentId()) {
|
|
|
|
|
+ <div class="typing-hold" aria-live="polite">
|
|
|
|
|
+ @if (chat.peerTyping()) {
|
|
|
|
|
+ <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
<div class="composer-hold">
|
|
<div class="composer-hold">
|
|
|
<!--
|
|
<!--
|
|
|
暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
|
|
暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
|
|
@@ -1164,6 +1186,15 @@ interface ConversationRow {
|
|
|
padding: var(--sp-2) 0;
|
|
padding: var(--sp-2) 0;
|
|
|
}
|
|
}
|
|
|
/* 输入区与消息流同宽:宽屏下居中,窄屏贴边留 16px */
|
|
/* 输入区与消息流同宽:宽屏下居中,窄屏贴边留 16px */
|
|
|
|
|
+ .typing-hold {
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ /* 固定高度:指示器出现/消失都不改变布局 */
|
|
|
|
|
+ height: 24px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 0 max(var(--sp-4), calc((100% - 768px) / 2));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
.composer-hold {
|
|
.composer-hold {
|
|
|
flex-shrink: 0;
|
|
flex-shrink: 0;
|
|
|
padding: 0 max(var(--sp-4), calc((100% - 768px) / 2));
|
|
padding: 0 max(var(--sp-4), calc((100% - 768px) / 2));
|
|
@@ -1392,10 +1423,22 @@ interface ConversationRow {
|
|
|
grid-template-areas: 'avatar title task status actions';
|
|
grid-template-areas: 'avatar title task status actions';
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
column-gap: var(--sp-3);
|
|
column-gap: var(--sp-3);
|
|
|
- min-height: var(--desk-topbar-h);
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 固定高度而不是 min-height:内容变化(任务状态出现/消失)不得改变栏高,
|
|
|
|
|
+ * 否则下方消息区会跟着位移 —— 用户看到的"发送时跳动"。
|
|
|
|
|
+ */
|
|
|
|
|
+ height: var(--desk-topbar-h);
|
|
|
padding: 0 var(--sp-4);
|
|
padding: 0 var(--sp-4);
|
|
|
border-bottom: 1px solid var(--edge);
|
|
border-bottom: 1px solid var(--edge);
|
|
|
}
|
|
}
|
|
|
|
|
+ /* 任务状态槽位:定宽定高,内部截断 */
|
|
|
|
|
+ .task-slot {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ max-width: 220px;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ }
|
|
|
.chat-head > ui-avatar {
|
|
.chat-head > ui-avatar {
|
|
|
grid-area: avatar;
|
|
grid-area: avatar;
|
|
|
}
|
|
}
|
|
@@ -1998,13 +2041,20 @@ export class DkChatPage {
|
|
|
*/
|
|
*/
|
|
|
readonly messageRows = computed<MessageRow[]>(() => {
|
|
readonly messageRows = computed<MessageRow[]>(() => {
|
|
|
const rows: MessageRow[] = [];
|
|
const rows: MessageRow[] = [];
|
|
|
- let group: { text: string; summary: string }[] = [];
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 工具组的 key 必须用**组内第一条消息的 id**,不能用 rows.length。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原实现 `tool-${rows.length}-${summary}`:流式追加消息时 rows.length 会变,
|
|
|
|
|
+ * 导致同一个工具组在相邻两次渲染里 key 不同 → Angular 销毁重建整个组,
|
|
|
|
|
+ * 表现为「内容一下出现、一下消失」(实测复现:气泡数 2→1→2)。
|
|
|
|
|
+ */
|
|
|
|
|
+ let group: { text: string; summary: string; id: string }[] = [];
|
|
|
const flush = (): void => {
|
|
const flush = (): void => {
|
|
|
if (group.length === 0) return;
|
|
if (group.length === 0) return;
|
|
|
const first = group[0];
|
|
const first = group[0];
|
|
|
rows.push({
|
|
rows.push({
|
|
|
kind: 'tool',
|
|
kind: 'tool',
|
|
|
- key: `tool-${rows.length}-${first.summary}`,
|
|
|
|
|
|
|
+ key: `tool-${first.id}`,
|
|
|
block: {
|
|
block: {
|
|
|
kind: 'process',
|
|
kind: 'process',
|
|
|
steps: group.map((g) => ({ kind: 'tool' as const, label: g.summary, detail: g.text })),
|
|
steps: group.map((g) => ({ kind: 'tool' as const, label: g.summary, detail: g.text })),
|
|
@@ -2016,7 +2066,7 @@ export class DkChatPage {
|
|
|
// 空消息(撤回 / 表情回应 / 空 body)不渲染 —— 实测占 7/12
|
|
// 空消息(撤回 / 表情回应 / 空 body)不渲染 —— 实测占 7/12
|
|
|
if (!m.text?.trim() && !m.file) continue;
|
|
if (!m.text?.trim() && !m.file) continue;
|
|
|
if (m.sender === 'agent' && isToolActivityMessage(m.text)) {
|
|
if (m.sender === 'agent' && isToolActivityMessage(m.text)) {
|
|
|
- group.push({ text: m.text!.slice(0, 400), summary: toolActivitySummary(m.text!) });
|
|
|
|
|
|
|
+ group.push({ text: m.text!.slice(0, 400), summary: toolActivitySummary(m.text!), id: m.id });
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
flush();
|
|
flush();
|