feat(webui): add lightweight session messaging via mentions
This commit is contained in:
+34
-11
@@ -23,6 +23,7 @@ import type {
|
||||
ProviderOAuthLoginResult,
|
||||
ProviderSettingsUpdate,
|
||||
SessionDeleteResult,
|
||||
SessionListHandle,
|
||||
SessionAutomationsPayload,
|
||||
SettingsPayload,
|
||||
SettingsUpdate,
|
||||
@@ -166,6 +167,22 @@ function splitKey(key: string): { channel: string; chatId: string } {
|
||||
return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function normalizeSessionListHandle(value: unknown): SessionListHandle | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const handle = value as Partial<SessionListHandle>;
|
||||
const id = typeof handle.id === "string" ? handle.id.trim() : "";
|
||||
const name = typeof handle.name === "string" ? handle.name.trim() : "";
|
||||
if (
|
||||
!/^handle_[a-f0-9]{32}$/i.test(id)
|
||||
|| !name
|
||||
|| !/^[\p{L}\p{N}_-]+$/u.test(name)
|
||||
|| !Number.isInteger(handle.color_slot)
|
||||
|| (handle.color_slot ?? -1) < 0
|
||||
|| (handle.color_slot ?? 8) >= 8
|
||||
) return null;
|
||||
return { id, name, color_slot: handle.color_slot as number };
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
token: string,
|
||||
base: string = "",
|
||||
@@ -179,6 +196,7 @@ export async function listSessions(
|
||||
model_preset?: string | null;
|
||||
run_started_at?: number | null;
|
||||
workspace_scope?: WorkspaceScopePayload | null;
|
||||
handle?: SessionListHandle | null;
|
||||
};
|
||||
const body = await request<{ sessions: Row[] }>(
|
||||
`${base}/api/sessions`,
|
||||
@@ -186,17 +204,22 @@ export async function listSessions(
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return body.sessions.map((s) => ({
|
||||
key: s.key,
|
||||
...splitKey(s.key),
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
title: s.title ?? "",
|
||||
preview: s.preview ?? "",
|
||||
modelPreset: s.model_preset ?? null,
|
||||
runStartedAt: s.run_started_at ?? null,
|
||||
workspaceScope: s.workspace_scope ?? null,
|
||||
}));
|
||||
return body.sessions.map((s) => {
|
||||
const rawSession = normalizeSessionListHandle(s.handle);
|
||||
const handle = rawSession ? { ...rawSession, session_key: s.key } : null;
|
||||
return {
|
||||
key: s.key,
|
||||
...splitKey(s.key),
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
title: s.title ?? "",
|
||||
preview: s.preview ?? "",
|
||||
modelPreset: s.model_preset ?? null,
|
||||
runStartedAt: s.run_started_at ?? null,
|
||||
workspaceScope: s.workspace_scope ?? null,
|
||||
handle,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
OutboundCliAppMention,
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
SessionHandle,
|
||||
SessionMention,
|
||||
SidebarStatePayload,
|
||||
GoalStateWsPayload,
|
||||
@@ -195,7 +196,7 @@ export class NanobotClient {
|
||||
private knownChats = new Set<string>();
|
||||
/** Temporary chats are connection-owned and intentionally not reattached. */
|
||||
private temporaryChatIds = new Set<string>();
|
||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||
/** Per-chat run projection, started optimistically and reconciled by lifecycle events. */
|
||||
private runStartedAtByChatId = new Map<string, number>();
|
||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
||||
private runStartedAtByTurnKey = new Map<string, number>();
|
||||
@@ -537,6 +538,14 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private startRunLocally(chatId: string, turnId: string): void {
|
||||
const startedAt = Date.now() / 1000;
|
||||
this.runStartedAtByTurnKey.set(this.runSendKey(chatId, turnId), startedAt);
|
||||
const previous = this.runStartedAtByChatId.get(chatId);
|
||||
this.runStartedAtByChatId.set(chatId, startedAt);
|
||||
if (previous !== startedAt) this.emitRunStatus(chatId, startedAt);
|
||||
}
|
||||
|
||||
private settleRunTurn(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
this.clearPendingMessageSend(chatId, turnId);
|
||||
@@ -716,7 +725,7 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
|
||||
private recordRunStatus(chatId: string, ev: InboundEvent): void {
|
||||
if (ev.event === "turn_end") {
|
||||
this.recordRunCompletion(chatId, ev.turn_id);
|
||||
return;
|
||||
@@ -967,6 +976,7 @@ export class NanobotClient {
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
sessionMentions?: SessionMention[];
|
||||
sessionHandles?: SessionHandle[];
|
||||
quotedContext?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
turnId?: string;
|
||||
@@ -986,6 +996,9 @@ export class NanobotClient {
|
||||
...(options?.sessionMentions?.length
|
||||
? { session_mentions: options.sessionMentions }
|
||||
: {}),
|
||||
...(options?.sessionHandles?.length
|
||||
? { session_handles: options.sessionHandles }
|
||||
: {}),
|
||||
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
|
||||
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||
@@ -1004,7 +1017,10 @@ export class NanobotClient {
|
||||
}
|
||||
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
|
||||
const startsNewRun = options.startsNewRun !== false;
|
||||
if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
|
||||
if (startsNewRun) {
|
||||
this.advanceRunGeneration(chatId, options.turnId);
|
||||
this.startRunLocally(chatId, options.turnId);
|
||||
}
|
||||
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
|
||||
}
|
||||
this.queueSend(frame);
|
||||
@@ -1240,7 +1256,7 @@ export class NanobotClient {
|
||||
if (chatId) {
|
||||
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
|
||||
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
|
||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||
this.recordRunStatus(chatId, parsed);
|
||||
if (supersededRunCompletion) return;
|
||||
this.recordGoalStateSnapshot(chatId, parsed);
|
||||
this.dispatch(chatId, parsed);
|
||||
|
||||
+33
-1
@@ -66,6 +66,8 @@ export interface UIMessage {
|
||||
mcpPresets?: UIMcpPresetAttachment[];
|
||||
/** Persisted sessions explicitly referenced by this user turn. */
|
||||
sessionMentions?: SessionMention[];
|
||||
/** Active session handles structurally selected by this user turn. */
|
||||
sessionHandles?: SessionHandle[];
|
||||
/** Assistant turn: accumulated model reasoning / thinking text. Built up
|
||||
* incrementally from ``reasoning_delta`` frames; finalized when
|
||||
* ``reasoning_end`` arrives. */
|
||||
@@ -79,6 +81,8 @@ export interface UIMessage {
|
||||
completedAt?: number;
|
||||
/** Lightweight provenance for proactive assistant messages. */
|
||||
source?: UIMessageSource;
|
||||
/** Structured provenance for a message delivered by another session. */
|
||||
sessionMessage?: UISessionMessage;
|
||||
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
||||
turnId?: string;
|
||||
turnPhase?: UITurnPhase;
|
||||
@@ -110,13 +114,31 @@ export interface UIMcpPresetAttachment {
|
||||
}
|
||||
|
||||
export interface SessionMention {
|
||||
/** Text token inserted in the composer, without the leading @. */
|
||||
/** Text token inserted in the composer, without the leading #. */
|
||||
name: string;
|
||||
/** Stable persisted-session identifier used by read_session. */
|
||||
session_key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Exact public handle DTO returned by the session-list endpoint. */
|
||||
export interface SessionListHandle {
|
||||
id: string;
|
||||
name: string;
|
||||
color_slot: number;
|
||||
}
|
||||
|
||||
/** Public session handle enriched with its UI navigation target. */
|
||||
export interface SessionHandle extends SessionListHandle {
|
||||
session_key: string;
|
||||
}
|
||||
|
||||
export interface UISessionMessage {
|
||||
direction: "incoming" | "outgoing";
|
||||
message_id: string;
|
||||
session: SessionListHandle;
|
||||
}
|
||||
|
||||
export interface SessionAutomationJob {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -337,6 +359,8 @@ export interface ChatSummary {
|
||||
/** Unix epoch seconds when this session currently has a turn in flight. */
|
||||
runStartedAt?: number | null;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
/** Stable, server-owned @handle for this session. */
|
||||
handle?: SessionHandle | null;
|
||||
}
|
||||
|
||||
export type WorkspaceAccessMode = "restricted" | "full";
|
||||
@@ -1248,6 +1272,13 @@ export type InboundEvent =
|
||||
/** Optional structured payload on progress frames (channel-specific). */
|
||||
agent_ui?: AgentUIBlob;
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "session_message";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
created_at_ms: number;
|
||||
session_message: UISessionMessage;
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "file_edit";
|
||||
chat_id: string;
|
||||
@@ -1442,6 +1473,7 @@ export type Outbound =
|
||||
cli_apps?: OutboundCliAppMention[];
|
||||
mcp_presets?: OutboundMcpPresetMention[];
|
||||
session_mentions?: SessionMention[];
|
||||
session_handles?: SessionHandle[];
|
||||
quoted_context?: string;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
turn_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user