feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+71 -8
View File
@@ -38,6 +38,10 @@ export type TurnUnit =
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
| { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.content.trim().length > 0) return false;
@@ -48,24 +52,30 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
}
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] {
const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined;
const flushTurn = () => {
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
if (turnMessages.length === 0) return;
const visibleMessages = visibleMessagesForTurn(turnMessages);
const turnUnits: TurnUnit[] = [];
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
activityMessages = [];
};
for (const message of turnMessages) {
for (const message of orderedTurnMessages) {
if (isAgentActivityMember(message)) {
activityMessages.push(message);
continue;
@@ -74,34 +84,87 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
flushActivityMessages();
units.push({ type: "message", message: stripInlineReasoning(message) });
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue;
}
flushActivityMessages();
units.push({ type: "message", message });
turnUnits.push({ type: "message", message });
visibleIndex += 1;
}
flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = [];
activeTurnId = undefined;
};
for (const message of messages) {
if (message.role === "user") {
flushTurn();
units.push({ type: "message", message });
activeTurnId = message.turnId;
continue;
}
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
turnMessages.push(message);
}
flushTurn();
flushTurn(options);
return units;
}
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if (
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
return messages;
}
return messages
.map((message, index) => ({ message, index }))
.sort((left, right) => {
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.map(({ message }) => message);
}
function normalizeCompletedTurnUnits(
turnUnits: TurnUnit[],
options: NormalizeActivityTimelineOptions,
): TurnUnit[] {
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
let trailingStart = turnUnits.length - 1;
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
trailingStart -= 1;
}
const previous = turnUnits[trailingStart - 1];
if (
!previous
|| previous.type !== "message"
|| previous.message.role !== "assistant"
) {
return turnUnits;
}
return [
...turnUnits.slice(0, trailingStart - 1),
...turnUnits.slice(trailingStart),
previous,
];
}
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
const visibleMessages: UIMessage[] = [];
for (const message of messages) {
+70
View File
@@ -1,6 +1,7 @@
import type {
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
@@ -8,9 +9,12 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderSettingsUpdate,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SkillDetail,
SkillsPayload,
SlashCommand,
WebSearchSettingsUpdate,
WorkspacesPayload,
@@ -134,6 +138,60 @@ export async function fetchWebuiThread(
return (await res.json()) as WebuiThreadPersistedPayload;
}
export async function fetchFilePreview(
token: string,
key: string,
path: string,
base: string = "",
): Promise<FilePreviewPayload> {
const query = new URLSearchParams();
query.set("path", path);
return request<FilePreviewPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/file-preview?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSessionAutomations(
token: string,
key: string,
base: string = "",
): Promise<SessionAutomationsPayload> {
return request<SessionAutomationsPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/automations`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills(
token: string,
base: string = "",
): Promise<SkillsPayload> {
return request<SkillsPayload>(
`${base}/api/webui/skills`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkillDetail(
token: string,
name: string,
base: string = "",
): Promise<SkillDetail> {
return request<SkillDetail>(
`${base}/api/webui/skills/${encodeURIComponent(name)}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function deleteSession(
token: string,
key: string,
@@ -158,6 +216,18 @@ export async function fetchSettings(
);
}
export async function fetchSettingsUsage(
token: string,
base: string = "",
): Promise<NonNullable<SettingsPayload["usage"]>> {
return request<NonNullable<SettingsPayload["usage"]>>(
`${base}/api/settings/usage`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchWorkspaces(
token: string,
base: string = "",
+2
View File
@@ -336,6 +336,7 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
},
): void {
this.knownChats.add(chatId);
@@ -348,6 +349,7 @@ export class NanobotClient {
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
webui: true,
};
this.queueSend(frame);
+124 -14
View File
@@ -4,6 +4,8 @@ export type Role = "user" | "assistant" | "tool" | "system";
* progress pings) that should not be rendered as conversational replies. */
export type MessageKind = "message" | "trace";
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
/** One image attached to a UIMessage.
*
* ``url`` can arrive in three different shapes, which the bubble renders
@@ -30,6 +32,8 @@ export interface UIMediaAttachment {
name?: string;
}
export interface UIMessageSource { kind: "cron"; label?: string; }
export interface UIMessage {
id: string;
role: Role;
@@ -64,6 +68,12 @@ export interface UIMessage {
reasoningStreaming?: boolean;
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
latencyMs?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string;
turnPhase?: UITurnPhase;
turnSeq?: number;
}
export interface UICliAppAttachment {
@@ -86,6 +96,50 @@ export interface UIMcpPresetAttachment {
brand_color?: string | null;
}
export interface SessionAutomationJob {
id: string;
name: string;
enabled: boolean;
schedule: {
kind: "at" | "every" | "cron" | string;
at_ms?: number | null;
every_ms?: number | null;
expr?: string | null;
tz?: string | null;
};
payload: {
message: string;
};
state: {
next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
};
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface SkillSummary {
name: string;
description: string;
source: "workspace" | "builtin" | string;
available: boolean;
unavailable_reason?: string;
}
export interface SkillRequirements {
bins: string[];
env: string[];
missing_bins: string[];
missing_env: string[];
}
export interface SkillDetail extends SkillSummary {
requirements: SkillRequirements;
raw_markdown: string;
}
export interface SkillsPayload { skills: SkillSummary[]; }
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@@ -352,6 +406,43 @@ export interface SettingsPayload {
};
unified_session: boolean;
};
usage?: {
days: Array<{
date: string;
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
sources?: Record<
"user" | "api" | "cron" | "dream" | "system" | string,
{
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
}
>;
}>;
total_tokens: number;
total_tokens_30d: number;
total_tokens_365d: number;
peak_day_tokens: number;
current_streak_days: number;
longest_streak_days: number;
active_days_30d: number;
requests_30d: number;
updated_at?: string | null;
};
advanced: {
restrict_to_workspace: boolean;
workspace_sandbox?: {
@@ -605,10 +696,16 @@ export type ConnectionStatus =
| "closed"
| "error";
export interface InboundTurnMetadata {
turn_id?: string;
turn_phase?: UITurnPhase;
turn_seq?: number;
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string }
| {
| ({
event: "message";
chat_id: string;
text: string;
@@ -621,49 +718,51 @@ export type InboundEvent =
kind?: "tool_hint" | "progress" | "reasoning";
/** Server-measured turn wall time when this frame finishes an assistant reply. */
latency_ms?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
}
| {
} & InboundTurnMetadata)
| ({
event: "file_edit";
chat_id: string;
edits: UIFileEdit[];
}
| {
} & InboundTurnMetadata)
| ({
event: "delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "stream_end";
chat_id: string;
stream_id?: string;
text?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "reasoning_delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "reasoning_end";
chat_id: string;
stream_id?: string;
}
} & InboundTurnMetadata)
| {
event: "runtime_model_updated";
model_name: string;
model_preset?: string | null;
}
| {
| ({
event: "turn_end";
chat_id: string;
latency_ms?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload;
}
} & InboundTurnMetadata)
| {
event: "goal_status";
chat_id: string;
@@ -732,6 +831,16 @@ export interface WebuiThreadPersistedPayload {
workspace_scope?: WorkspaceScopePayload;
}
export interface FilePreviewPayload {
path: string;
display_path: string;
project_path: string;
language: string;
content: string;
size: number;
truncated: boolean;
}
export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string }
@@ -745,6 +854,7 @@ export type Outbound =
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;