Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App/frontend/desktop/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,7 +1239,7 @@ export const zhCNMessages = {
"agent.activity.thinking": "思考中",
"agent.activity.workedBriefly": "工作了一会儿",
"agent.activity.workedFor": "工作了 {duration}",
"agent.activity.thoughtBriefly": "思考一下",
"agent.activity.thoughtBriefly": "简短思考",
"agent.activity.thoughtFor": "已思考 {duration}",
"agent.activity.stepsDone": "执行了 {count} 步",
"agent.activity.stepsDoneOne": "执行了 1 步",
Expand Down
12 changes: 9 additions & 3 deletions App/frontend/desktop/src/pages/agent-thread-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2501,9 +2501,6 @@ function ThoughtSegmentBlock(props: { text: string; isLive: boolean; isOnlySegme
: <ChevronRight size={12} aria-hidden="true" className="agent-narration-block__chevron" />}
</button>
) : null}
{!props.isOnlySegment && props.isLive && open ? (
<p className="agent-activity-segment__live-thought-label">{props.t("agent.activity.thinking")}</p>
) : null}
{open ? (
<div className="agent-activity-segment__body">
<ReasoningMarkdown text={props.text} />
Expand All @@ -2522,6 +2519,15 @@ function ThoughtSegmentBlock(props: { text: string; isLive: boolean; isOnlySegme
*/
function useFollowAlongOpen(isLive: boolean): { open: boolean; onToggle: (next: boolean) => void } {
const [manualOpen, setManualOpen] = useState<boolean | null>(null);
const wasLiveRef = useRef(isLive);

useEffect(() => {
if (wasLiveRef.current && !isLive) {
setManualOpen(null);
}
wasLiveRef.current = isLive;
}, [isLive]);

return {
open: manualOpen ?? isLive,
onToggle: (next: boolean) => setManualOpen(next)
Expand Down
13 changes: 10 additions & 3 deletions App/frontend/desktop/src/pages/conversation-features.css
Original file line number Diff line number Diff line change
Expand Up @@ -2914,16 +2914,20 @@
padding: 6px 11px;
border: 0;
border-radius: var(--radius-btn, 8px);
background: color-mix(in srgb, var(--color-action-sky) 4%, var(--color-background-paper));
background: color-mix(in srgb, var(--color-text-ink) 3%, var(--color-background-paper));
color: color-mix(in srgb, var(--color-text-ink) 66%, transparent);
font-size: 12px;
line-height: 18px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.pinned-command-chip--legal-shortcut {
min-width: 112px;
justify-content: center;
}
.pinned-command-chip:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-action-sky) 10%, var(--color-background-paper));
color: var(--color-action-sky-hover);
background: color-mix(in srgb, var(--color-text-ink) 7%, var(--color-background-paper));
color: var(--color-text-ink);
}
.pinned-command-chip:focus-visible {
outline: none;
Expand All @@ -2937,6 +2941,9 @@
background: color-mix(in srgb, var(--color-action-sky) 14%, var(--color-background-paper));
color: var(--color-action-sky-hover);
}
.pinned-command-chip[data-open="true"]:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-action-sky) 20%, var(--color-background-paper));
}
.agent-conversation-panel[data-waiting-card="true"] .agent-conversation-scroll {
padding-bottom: calc(var(--agent-question-overlay-height, var(--agent-composer-overlay-height, 120px)) + 8px);
}
9 changes: 8 additions & 1 deletion App/frontend/desktop/src/pages/conversation-features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ export function conversationFeaturePanelOverlays(width: number | null | undefine
return width != null && width > 0 && width < SHARED_ROW_PRIMARY_RESERVE + SHARED_ROW_MIN_PANE_WIDTH;
}

export function recordingShortcutOpen(compactOpen: boolean,
pluginRecording: Pick<RecordingPanelSession, "status"> | null | undefined, pluginRecordingHidden: boolean): boolean {
// A completed interview may remain in the right panel, but this shortcut
// has already switched back to controlling the independent recording card.
return compactOpen || Boolean(pluginRecording && pluginRecording.status !== "done" && !pluginRecordingHidden);
}

function PersistentPluginRecorder(props: {
owner: string; host: ReactElement<ComponentProps<typeof PluginCapabilityHost>> | null;
onSession(owner: string, session: RecordingPanelSession | null): void;
Expand Down Expand Up @@ -269,7 +276,7 @@ export function useConversationFeatures(options: {
? selectPinnedCommandsForLegalWorkflow(selectConversationPinnedCommands(targets, calls), legalFlowCardOpen)
: [];
const shortcuts = <PinnedPluginCommandBar targets={pinnedTargets} disabled={!clients}
recordingOpen={recording.compactOpen || Boolean(pluginRecording && !hiddenRecordings.has(recordingOwner))}
recordingOpen={recordingShortcutOpen(recording.compactOpen, pluginRecording, hiddenRecordings.has(recordingOwner))}
active={selectOpenPinnedCapabilities(prepared, ui?.dismissedCallIds, pinnedCapabilities, ui?.hiddenCallIds)}
onRecordingPress={toggleCompactRecording} onPress={({ plugin, command }) => void invoke(plugin, command)} />;
const artifactPanel = preview && clients?.plugins ? <PluginArtifactPreviewPanel artifact={preview}
Expand Down
9 changes: 8 additions & 1 deletion App/frontend/desktop/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
import { AgentAttachmentCard, splitAgentAttachmentName } from "./agent-file-attachment-chip.js";
import { AgentGoalBar, type AgentGoalControlRequest } from "./agent-goal-bar.js";
import { AgentQueuedMessageList, queuedComposerEditDecision } from "./agent-queued-message-list.js";
import { agentOutputSignature, useStalledOutputShimmer } from "./agent-stall-shimmer.js";
import { AgentThreadMessages, ChatImageLightbox } from "./agent-thread-messages.js";
import { AgentThreadPanel, PLUGIN_RECORDING_TAB_ID, useAgentThreadPanel } from "./agent-thread-panel.js";
import { hasPendingBrowserOpen, MEMMY_BROWSER_OPEN_EVENT } from "./browser-panel.js";
Expand Down Expand Up @@ -946,6 +947,7 @@ export function HomePage() {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [workspaceLayoutRef, workspaceRowWidth] = useSharedRowWidth<HTMLDivElement>();
const conversationPanelRef = useRef<HTMLElement | null>(null);
const conversationContentRef = useRef<HTMLDivElement | null>(null);
const composerOverlayRef = useRef<HTMLDivElement | null>(null);
const [questionDockTarget, setQuestionDockTarget] = useState<HTMLDivElement | null>(null);
const pendingStatusChatRef = useRef<string | null>(null);
Expand Down Expand Up @@ -1203,6 +1205,11 @@ export function HomePage() {
state.agent.optimisticSendingByChatId[state.agent.currentChatId]
)
);
useStalledOutputShimmer(
conversationContentRef,
(isCurrentAgentRunning || isCurrentGoalActive) && !pendingCardKey,
agentOutputSignature(state.agent.messages)
);
const currentTaskCompletedUnseen = state.agent.chatWindowFocused && state.agent.chatViewVisible && !state.agent.blankDraftActive
? false
: state.agent.tasks.find((task) => task.chatId === state.agent.currentChatId)?.completedUnseen;
Expand Down Expand Up @@ -3182,7 +3189,7 @@ export function HomePage() {
onWheel={markAgentConversationUserScrollIntent}
onTouchMove={markAgentConversationUserScrollIntent}
>
<div className="agent-conversation-content agent-conversation-column space-y-3">
<div ref={conversationContentRef} className="agent-conversation-content agent-conversation-column space-y-3">
{displayConnectionStatus !== "connected" && (
<div className="text-center">
<span className="inline-flex text-[11px] px-3 py-1 rounded-tag bg-background-paper text-text-ink/55 border border-border-stone/30">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,15 @@ export function PinnedPluginCommandBar(props: {
// of the top-bar page and pending material collection calls.
const recording = target.command.topbar === true;
const open = recording ? props.recordingOpen : props.active.has(`${target.plugin.id}:${target.command.capabilityId}`);
const legalShortcut = target.plugin.id === "legal-labor"
&& (target.command.command === "/legal-guide" || target.command.command === "/legal-record");
return (
<button
key={`${target.plugin.id}:${target.command.command}`}
type="button"
disabled={props.disabled}
title={target.command.description}
className="pinned-command-chip"
className={`pinned-command-chip${legalShortcut ? " pinned-command-chip--legal-shortcut" : ""}`}
// These buttons stay on screen while their card is open, so without
// this the card and the button that raised it look unrelated.
data-open={open ? "true" : undefined}
Expand Down Expand Up @@ -345,4 +347,4 @@ export function selectPluginCallsForConversation(
const normalizedActiveId = normalizePluginConversationId(activeConversationId);
if (!normalizedActiveId) return [];
return calls.filter((call) => normalizePluginConversationId(call.conversationId) === normalizedActiveId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -664,11 +664,11 @@ describe("AgentThreadMessages", () => {
expect(html).toContain("agent-activity-segment--narration");
expect(html).toContain(draft);
expect(html).not.toContain("agent-narration-block__preview");
expect(html).toContain("思考一下");
expect(html).toContain("简短思考");
expect(html).not.toContain("先规划三轮循环。");
expect(html).not.toContain("agent-chat-bubble--assistant");
expect(html).not.toContain("agent-message-copy-button--left");
expect(html.indexOf("思考一下")).toBeLessThan(html.indexOf("我来帮你规划国庆出行方案"));
expect(html.indexOf("简短思考")).toBeLessThan(html.indexOf("我来帮你规划国庆出行方案"));
});

it("counts every row in the group label — web search plus grep reads 浏览了 2 处", () => {
Expand Down Expand Up @@ -1078,15 +1078,15 @@ describe("AgentThreadMessages", () => {
expect(html).toContain("工作中");
// The thought was superseded by the tool step, so it folds to its
// one-line label; the prose re-appears on demand, not by default.
expect(html).toContain("思考一下");
expect(html).toContain("简短思考");
expect(html).not.toContain("先判断要不要查资料。");
expect(html).toContain("准备读取本地说明。");
expect(html).not.toContain("正在执行 2 个步骤");
expect(html).not.toContain("执行了 2 个步骤");
expect(html).not.toContain("text-status-success");
});

it("labels the live thought after a tool so the older thought heading cannot appear to own it", () => {
it("keeps the live thought after a tool while the earlier thought folds", () => {
const html = renderToString(
<I18nProvider language="zh-CN">
<AgentThreadMessages chatScopeKey="chat-thought-tool-thought" isSending messages={[
Expand All @@ -1100,14 +1100,13 @@ describe("AgentThreadMessages", () => {
</I18nProvider>
);

const oldThought = html.indexOf("思考一下");
const oldThought = html.indexOf("简短思考");
const tool = html.indexOf("Generate review sections");
const liveThought = html.indexOf("agent-activity-segment__live-thought-label");
const liveBody = html.indexOf("All 4 paragraphs saved; continue.");
expect(oldThought).toBeGreaterThanOrEqual(0);
expect(tool).toBeGreaterThan(oldThought);
expect(liveThought).toBeGreaterThan(tool);
expect(liveBody).toBeGreaterThan(liveThought);
expect(liveBody).toBeGreaterThan(tool);
expect(html).not.toContain("agent-activity-segment__live-thought-label");
expect(html).not.toContain("先生成小节。");
});

Expand Down Expand Up @@ -1145,7 +1144,7 @@ describe("AgentThreadMessages", () => {
expect(html).toContain("Keep markdown lists compact.");
expect(html).toContain("<strong>Decision</strong>");
expect(html).not.toContain("The user wants a detailed introduction<br");
expect(html).not.toContain("思考一下</span>");
expect(html).not.toContain("简短思考</span>");
});

it("renders the complete decoded Windows error in tool details", () => {
Expand Down Expand Up @@ -1629,7 +1628,7 @@ describe("AgentThreadMessages", () => {
// header, separate from the always-visible final answer bubble below it.
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('data-icon="chevron-right"');
expect(html).toContain("思考一下");
expect(html).toContain("简短思考");
expect(html).not.toContain("data-reasoning-key=");
expect(html).toContain("最终回答");
// Reasoning body is only rendered when the user expands the collapsed cluster.
Expand Down Expand Up @@ -1681,7 +1680,7 @@ describe("AgentThreadMessages", () => {
// Reasoning finished but answer is still streaming → cluster collapses (reasoning not running).
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('data-icon="chevron-right"');
expect(html).toContain("思考一下");
expect(html).toContain("简短思考");
expect(html).not.toContain("data-reasoning-key=");
expect(html).toContain("正在回答");
// Reasoning body is only rendered when the user expands the collapsed cluster.
Expand Down Expand Up @@ -1711,7 +1710,7 @@ describe("AgentThreadMessages", () => {
</I18nProvider>
);

expect(html).toContain("思考一下");
expect(html).toContain("简短思考");
expect(html).toContain("data-activity-key=");
expect(html).toContain("完成。");
expect(html).not.toContain("data-reasoning-key=");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "../../i18n/i18n-provider.js";
import { PluginUiProvider, usePluginUi } from "../../app/plugin-ui-context.js";
import { conversationFeaturePanelOverlays, recordingArchiveMaterials, useConversationFeatures } from "../conversation-features.js";
import { conversationFeaturePanelOverlays, recordingArchiveMaterials, recordingShortcutOpen, useConversationFeatures } from "../conversation-features.js";
import type { AgentChatMessage } from "../../state/agent-chat-slice.js";

(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -91,6 +91,13 @@ describe("conversation feature integration", () => {
expect(features.pluginRecordingSession?.status).toBe("idle");
});

it("does not keep the recording shortcut active for a completed interview", () => {
expect(recordingShortcutOpen(false, { status: "recording" }, false)).toBe(true);
expect(recordingShortcutOpen(false, { status: "done" }, false)).toBe(false);
expect(recordingShortcutOpen(false, { status: "recording" }, true)).toBe(false);
expect(recordingShortcutOpen(true, { status: "done" }, false)).toBe(true);
});

it("overlays a standalone artifact preview when both columns cannot fit", () => {
expect(conversationFeaturePanelOverlays(659)).toBe(true);
expect(conversationFeaturePanelOverlays(660)).toBe(false);
Expand Down
42 changes: 34 additions & 8 deletions App/frontend/desktop/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3730,6 +3730,40 @@ body.memmy-thread-panel-fullscreen .app-frame-main {
margin-bottom: 0;
}

/* A quiet running turn sweeps its last visible line after three seconds. */
.agent-stall-shimmer {
background-image:
linear-gradient(90deg, transparent, var(--color-action-sky-hover), transparent),
linear-gradient(var(--agent-stall-ink), var(--agent-stall-ink));
background-repeat: no-repeat;
background-size: var(--agent-stall-band) var(--agent-stall-height), 100% 100%;
background-position: var(--agent-stall-from) var(--agent-stall-y), 0 0;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: agent-stall-sweep 1.8s linear infinite;
}

.agent-stall-shimmer :where(code, .agent-activity-diff, .agent-activity-diff *) {
-webkit-text-fill-color: currentColor;
}

@keyframes agent-stall-sweep {
from {
background-position: var(--agent-stall-from) var(--agent-stall-y), 0 0;
}
to {
background-position: var(--agent-stall-to) var(--agent-stall-y), 0 0;
}
}

@media (prefers-reduced-motion: reduce) {
.agent-stall-shimmer {
animation: none;
background-position: calc((var(--agent-stall-from) + var(--agent-stall-to)) / 2) var(--agent-stall-y), 0 0;
}
}

.agent-thinking-pulse {
display: inline-block;
width: 6px;
Expand Down Expand Up @@ -4301,14 +4335,6 @@ button.agent-activity-file-link.agent-activity-timeline-item__detail {
margin: 8px 0 4px;
}

.agent-activity-segment__live-thought-label {
margin: 0;
padding: 2px 0;
color: color-mix(in srgb, var(--color-text-ink) 50%, transparent);
font-size: 15px;
line-height: 26.25px;
}

/* Thinking-chain rail: a 2px hairline-coloured bar spanning the full height
of the reasoning text, 12px clear of the glyphs. */
.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning,
Expand Down
Loading