mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: session rail with read-only session companion in Control UI chat (#113698)
* feat(protocol): add session companion schemas * feat(gateway): add session companion service * feat(gateway): expose session companion rpc * fix(gateway): harden companion runtime limits * test(gateway): fix companion type assertions * test(gateway): align companion test target * feat(ui): replace observer HUD and side chat with session rail * docs: session rail companion for control ui * test(ui): align session rail client mock * fix(ui): drop stale chat search import * fix: satisfy session companion lint contracts * refactor(gateway): isolate companion state contract * fix(ui): keep session rail reachable while idle * fix(agents): clamp derived openai prompt cache keys at boundary * fix(ai): clamp chatgpt responses session_id affinity header * fix(ci): align session companion branch gates * fix(ui): require run id for sessionless terminal chat events * fix(agents): scope internal run events to transcript * chore: revert changelog edit (release generation owns changelog)
This commit is contained in:
committed by
GitHub
parent
78e600bc1c
commit
a95486cefc
@@ -495,6 +495,9 @@ enum class GatewayMethod(
|
||||
SessionSuggestionsList("session.suggestions.list"),
|
||||
SessionSuggestionsResolve("session.suggestions.resolve"),
|
||||
SessionTyping("session.typing"),
|
||||
SessionsCompanionAsk("sessions.companion.ask"),
|
||||
SessionsCompanionState("sessions.companion.state"),
|
||||
SessionsCompanionReset("sessions.companion.reset"),
|
||||
}
|
||||
|
||||
enum class GatewayEvent(
|
||||
|
||||
@@ -4910,6 +4910,28 @@ public struct SessionObserverDigest: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionCompanionExchange: Codable, Sendable {
|
||||
public let question: String
|
||||
public let answer: String
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
question: String,
|
||||
answer: String,
|
||||
ts: Int)
|
||||
{
|
||||
self.question = question
|
||||
self.answer = answer
|
||||
self.ts = ts
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case question
|
||||
case answer
|
||||
case ts
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionRow: Codable, Sendable {
|
||||
public let key: String
|
||||
public let sessionid: String?
|
||||
@@ -5132,6 +5154,98 @@ public struct SessionRow: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompanionAskParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let question: String
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
question: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.question = question
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case question
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompanionAskResult: Codable, Sendable {
|
||||
public let answer: String
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
answer: String,
|
||||
ts: Int)
|
||||
{
|
||||
self.answer = answer
|
||||
self.ts = ts
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case answer
|
||||
case ts
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompanionResetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
|
||||
public init(
|
||||
sessionkey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompanionResetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
|
||||
public init(
|
||||
ok: Bool)
|
||||
{
|
||||
self.ok = ok
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompanionStateParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
|
||||
public init(
|
||||
sessionkey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompanionStateResult: Codable, Sendable {
|
||||
public let exchanges: [SessionCompanionExchange]
|
||||
|
||||
public init(
|
||||
exchanges: [SessionCompanionExchange])
|
||||
{
|
||||
self.exchanges = exchanges
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case exchanges
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsObserverAskParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let question: String
|
||||
|
||||
+19
-15
@@ -11,6 +11,8 @@ session** without adding it to conversation history. It is modeled after
|
||||
Claude Code's `/btw`, adapted to OpenClaw's Gateway and multi-channel
|
||||
architecture.
|
||||
|
||||
In the Control UI, both commands route to the session rail companion instead of the detached BTW runner. The companion keeps a bounded multi-turn thread in Gateway memory and can use read-only session-history/search and workspace-read tools. TUI and external-channel behavior is unchanged.
|
||||
|
||||
```text
|
||||
/btw what changed?
|
||||
/side what does this error mean?
|
||||
@@ -45,33 +47,35 @@ use a direct one-shot provider call instead.
|
||||
## What it does not do
|
||||
|
||||
`/btw` does not create a durable session, continue the unfinished main task,
|
||||
persist question/answer data to transcript history, or survive a reload.
|
||||
or persist question/answer data to transcript history. Detached BTW results do
|
||||
not survive a reload. The Control UI companion can rehydrate its in-memory
|
||||
thread after a reload, but the thread is cleared by a session reset, Gateway
|
||||
restart, idle expiry, or the rail's clear button.
|
||||
|
||||
## Delivery model
|
||||
|
||||
Normal assistant chat uses the Gateway `chat` event. BTW uses a separate
|
||||
`chat.side_result` event so clients cannot mistake it for regular
|
||||
conversation history. Because it is not replayed from `chat.history`, it
|
||||
disappears after reload.
|
||||
Normal assistant chat uses the Gateway `chat` event. Detached BTW uses a
|
||||
separate `chat.side_result` event so clients cannot mistake it for regular
|
||||
conversation history. The Control UI does not consume that event; it calls the
|
||||
session companion RPCs and renders their bounded exchange state in the rail.
|
||||
|
||||
## Surface behavior
|
||||
|
||||
| Surface | Behavior |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| TUI | Rendered inline in the chat log, visibly distinct from a normal reply, dismissible with `Enter` or `Esc`. |
|
||||
| External channels | Delivered as a clearly labeled one-off reply (Telegram, WhatsApp, Discord have no local ephemeral overlay). |
|
||||
| Control UI / web | Rendered as a floating "Side chat" panel pinned to the thread. Answers accumulate as turns and a "Follow up" input asks the next side question. Close (`Esc` or the X) keeps the conversation and reopens on the next answer; the trash button discards it and stops a pending run. |
|
||||
| Surface | Behavior |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| TUI | Rendered inline in the chat log, visibly distinct from a normal reply, dismissible with `Enter` or `Esc`. |
|
||||
| External channels | Delivered as a clearly labeled one-off reply (Telegram, WhatsApp, Discord have no local ephemeral overlay). |
|
||||
| Control UI / web | Routes `/btw` and `/side` to the expanded session rail companion. The read-only thread is keyed by session, rehydrates from Gateway memory, and can be cleared with the trash button. `Esc` collapses the rail. |
|
||||
|
||||
## Selection popup (Control UI)
|
||||
|
||||
Highlighting text inside a chat message in the Control UI opens a small
|
||||
selection popup with two actions:
|
||||
|
||||
- **More details** immediately sends an implicit `/btw` question asking the
|
||||
model to explain the highlighted text in the context of the current
|
||||
session. The answer arrives in the floating side chat panel.
|
||||
- **Ask in side chat** pre-fills the composer with a `/btw` draft quoting the
|
||||
highlighted text so you can type your own question about it.
|
||||
- **More details** immediately asks the session rail companion to explain the
|
||||
highlighted text in the context of the current session.
|
||||
- **Ask in side chat** opens the rail and pre-fills its composer with a quoted
|
||||
draft so you can type your own question about the selection.
|
||||
|
||||
Both actions follow normal `/btw` semantics: the question and answer stay out
|
||||
of session history and the main run is left untouched.
|
||||
|
||||
@@ -481,6 +481,10 @@ Unlike a normal message:
|
||||
- Does **not** change future session context.
|
||||
- Is not written to transcript history.
|
||||
|
||||
In the Control UI, `/btw` and `/side` open the session rail and ask its
|
||||
read-only companion instead of starting the detached BTW path. The TUI and
|
||||
external-channel behavior above is unchanged.
|
||||
|
||||
See [BTW side questions](/tools/btw) for the full behavior.
|
||||
|
||||
## Surface notes
|
||||
|
||||
@@ -14,9 +14,11 @@ The Control UI is a small **Vite + Lit** single-page app served by the Gateway:
|
||||
|
||||
It speaks **directly to the Gateway WebSocket** on the same port.
|
||||
|
||||
While you watch a running session, the Gateway shows the model's latest safe preamble immediately as the session headline. When a utility model is available, it can replace that headline with a richer compact status digest after enough activity accumulates. Chat shows the result as a one-line status pill that expands into a card with the assessment, plan progress, pull requests, and elapsed time. The card can expand once when a run becomes stuck or needs input; the `/btw` side chat takes priority over the expanded card.
|
||||
While you watch a running session, the Gateway shows the model's latest safe preamble immediately as the session headline. When a utility model is available, it can replace that headline with a richer compact status digest after enough activity accumulates. Chat carries the result in a **session rail**: its compact pill shows the live digest, while the expanded rail shows the assessment, plan progress, pull requests, elapsed time, and a read-only companion thread. The rail can expand once when a run becomes stuck or needs input, and done or failed runs keep a frozen “finished” time based on the final digest. On wide chat panes the expanded rail docks as a 400 px right column; on narrower and mobile layouts it remains an overlay.
|
||||
|
||||
The expanded card also accepts short questions about the run. Answers use only the observer's current digest and sanitized bounded notes, stay in the browser for that session, and never enter or interrupt the main agent run. If the observations do not contain the answer, the observer says that it cannot know.
|
||||
The companion answers questions about the selected session and its project without entering or interrupting the main agent run. It uses the utility model with read-only access to the target session's history/search and agent workspace. The bounded thread is held in Gateway memory, is restored when you switch sessions in the Control UI, and is cleared by the rail's trash button, a session reset, Gateway restart, or idle expiry. It never enters `chat.history`. Type `/btw <question>` or `/side <question>` in the main Control UI composer to open the rail and ask there; other clients keep their existing BTW behavior.
|
||||
|
||||
Highlighting text in a chat message offers **More details**, which asks the companion immediately, and **Ask in side chat**, which opens the rail with a quoted draft ready to edit.
|
||||
|
||||
The headline owns that run's sidebar subtitle instead of heuristic live activity. It is shared with the official iOS and Android session lists. A final done or failed digest remains visible while the session is unread, then the row returns to its normal work subtitle.
|
||||
|
||||
|
||||
@@ -4,3 +4,7 @@ export {
|
||||
registerBuiltInApiProviders,
|
||||
resetApiProviders,
|
||||
} from "./providers/register-builtins.js";
|
||||
export {
|
||||
clampOpenAIPromptCacheKey,
|
||||
OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH,
|
||||
} from "./providers/openai-prompt-cache.js";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Context, Model } from "@openclaw/llm-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH } from "../providers/openai-prompt-cache.js";
|
||||
import { buildOpenAIClientHeaders } from "./openai-transport-params.js";
|
||||
|
||||
const codexModel = {
|
||||
id: "gpt-5.6-luna",
|
||||
provider: "openai",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
} as Model;
|
||||
|
||||
const context = { messages: [] } as unknown as Context;
|
||||
|
||||
describe("buildOpenAIClientHeaders session_id affinity header", () => {
|
||||
it("clamps long internal session ids to the backend's 64-char cache key limit", () => {
|
||||
const longSessionId = `internal-session-effects-session-companion-${"a".repeat(50)}`;
|
||||
const headers = buildOpenAIClientHeaders(
|
||||
codexModel,
|
||||
context,
|
||||
undefined,
|
||||
undefined,
|
||||
longSessionId,
|
||||
);
|
||||
const sessionHeader = headers.session_id;
|
||||
expect(sessionHeader).toBeDefined();
|
||||
expect(Array.from(sessionHeader ?? "").length).toBeLessThanOrEqual(
|
||||
OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH,
|
||||
);
|
||||
expect(sessionHeader?.startsWith("internal-session-effects-session-companion-")).toBe(true);
|
||||
});
|
||||
|
||||
it("passes short session ids through unchanged", () => {
|
||||
const headers = buildOpenAIClientHeaders(codexModel, context, undefined, undefined, "abc-123");
|
||||
expect(headers.session_id).toBe("abc-123");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveOpenAIProjectedToolsStrictToolFlag,
|
||||
type OpenAIToolProjection,
|
||||
} from "../internal/openai.js";
|
||||
import { clampOpenAIPromptCacheKey } from "../providers/openai-prompt-cache.js";
|
||||
import { resolveModelRequestTimeoutMs, resolveProviderRequestPolicyConfig } from "./host-policy.js";
|
||||
import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
|
||||
import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js";
|
||||
@@ -216,7 +217,10 @@ export function buildOpenAIClientHeaders(
|
||||
) &&
|
||||
usesNativeOpenAICodexResponsesBackend(model)
|
||||
) {
|
||||
resolvedHeaders.session_id = sessionId;
|
||||
// The backend derives its prompt cache key from this header and enforces
|
||||
// OpenAI's 64-char limit server-side; long internal session ids
|
||||
// (companion/btw effects sessions) 400 without this clamp.
|
||||
resolvedHeaders.session_id = clampOpenAIPromptCacheKey(sessionId) ?? sessionId;
|
||||
}
|
||||
return resolvedHeaders;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||
export const GatewayErrorDetailCodes = {
|
||||
MISSING_SCOPE: "MISSING_SCOPE",
|
||||
MCP_APP_VIEW_EXPIRED: "MCP_APP_VIEW_EXPIRED",
|
||||
SESSION_COMPANION_BUSY: "SESSION_COMPANION_BUSY",
|
||||
SESSION_OBSERVER_BUSY: "SESSION_OBSERVER_BUSY",
|
||||
SESSION_OBSERVER_UNAVAILABLE: "SESSION_OBSERVER_UNAVAILABLE",
|
||||
UNKNOWN_AGENT_ID: "UNKNOWN_AGENT_ID",
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
validateNodeSkillsUpdateParams,
|
||||
validateNodePresenceActivityPayload,
|
||||
validateSessionsListParams,
|
||||
validateSessionsCompanionAskParams,
|
||||
validateSessionsCompanionResetParams,
|
||||
validateSessionsCompanionStateParams,
|
||||
validateSessionsObserverAskParams,
|
||||
validateSessionsObserverVisibilityParams,
|
||||
validateSessionsSearchParams,
|
||||
@@ -293,6 +296,33 @@ describe("lazy protocol validators", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("validates closed bounded session companion params", () => {
|
||||
expect(
|
||||
validateSessionsCompanionAskParams({
|
||||
sessionKey: "agent:main:current",
|
||||
question: "What changed in the project?",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateSessionsCompanionAskParams({
|
||||
sessionKey: "agent:main:current",
|
||||
question: "x".repeat(401),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(validateSessionsCompanionAskParams({ sessionKey: "", question: "why" })).toBe(false);
|
||||
expect(
|
||||
validateSessionsCompanionAskParams({
|
||||
sessionKey: "agent:main:current",
|
||||
question: "why",
|
||||
extra: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(validateSessionsCompanionStateParams({ sessionKey: "agent:main:current" })).toBe(true);
|
||||
expect(validateSessionsCompanionStateParams({ sessionKey: "" })).toBe(false);
|
||||
expect(validateSessionsCompanionResetParams({ sessionKey: "agent:main:current" })).toBe(true);
|
||||
expect(validateSessionsCompanionResetParams({})).toBe(false);
|
||||
});
|
||||
|
||||
it("validates closed session observer visibility declarations", () => {
|
||||
expect(validateSessionsObserverVisibilityParams({ visible: true })).toBe(true);
|
||||
expect(validateSessionsObserverVisibilityParams({})).toBe(false);
|
||||
|
||||
@@ -448,6 +448,7 @@ import {
|
||||
SessionPlacementStateSchema,
|
||||
isCloudWorkerPlacementState,
|
||||
SESSION_OBSERVER_HEALTH_VALUES,
|
||||
SessionCompanionExchangeSchema,
|
||||
SessionObserverDigestSchema,
|
||||
SessionObserverHealthSchema,
|
||||
SessionObserverPlanProgressSchema,
|
||||
@@ -468,6 +469,12 @@ import {
|
||||
SessionVisibilitySchema,
|
||||
SessionVisibilitySetParamsSchema,
|
||||
SessionVisibilitySetResultSchema,
|
||||
SessionsCompanionAskParamsSchema,
|
||||
SessionsCompanionAskResultSchema,
|
||||
SessionsCompanionResetParamsSchema,
|
||||
SessionsCompanionResetResultSchema,
|
||||
SessionsCompanionStateParamsSchema,
|
||||
SessionsCompanionStateResultSchema,
|
||||
SessionsObserverAskParamsSchema,
|
||||
SessionsObserverAskResultSchema,
|
||||
SessionsObserverVisibilityParamsSchema,
|
||||
@@ -813,6 +820,9 @@ export const validateSessionsFilesGetParams = lazyCompile(SessionsFilesGetParams
|
||||
export const validateSessionsFilesSetParams = lazyCompile(SessionsFilesSetParamsSchema);
|
||||
export const validateSessionsFilesRevealParams = lazyCompile(SessionsFilesRevealParamsSchema);
|
||||
export const validateSessionsDiffParams = lazyCompile(SessionsDiffParamsSchema);
|
||||
export const validateSessionsCompanionAskParams = lazyCompile(SessionsCompanionAskParamsSchema);
|
||||
export const validateSessionsCompanionStateParams = lazyCompile(SessionsCompanionStateParamsSchema);
|
||||
export const validateSessionsCompanionResetParams = lazyCompile(SessionsCompanionResetParamsSchema);
|
||||
export const validateSessionsObserverAskParams = lazyCompile(SessionsObserverAskParamsSchema);
|
||||
export const validateSessionsObserverVisibilityParams = lazyCompile(
|
||||
SessionsObserverVisibilityParamsSchema,
|
||||
@@ -1209,6 +1219,7 @@ export {
|
||||
SessionPlacementStateSchema,
|
||||
SessionPlacementSchema,
|
||||
SESSION_OBSERVER_HEALTH_VALUES,
|
||||
SessionCompanionExchangeSchema,
|
||||
SessionObserverDigestSchema,
|
||||
SessionObserverHealthSchema,
|
||||
SessionObserverPlanProgressSchema,
|
||||
@@ -1225,6 +1236,12 @@ export {
|
||||
SessionVisibilitySchema,
|
||||
SessionVisibilitySetParamsSchema,
|
||||
SessionVisibilitySetResultSchema,
|
||||
SessionsCompanionAskParamsSchema,
|
||||
SessionsCompanionAskResultSchema,
|
||||
SessionsCompanionResetParamsSchema,
|
||||
SessionsCompanionResetResultSchema,
|
||||
SessionsCompanionStateParamsSchema,
|
||||
SessionsCompanionStateResultSchema,
|
||||
SessionsObserverAskParamsSchema,
|
||||
SessionsObserverAskResultSchema,
|
||||
SessionsObserverVisibilityParamsSchema,
|
||||
@@ -1897,6 +1914,7 @@ export type {
|
||||
SessionsDescribeParams,
|
||||
SessionsResolveParams,
|
||||
SessionOperationEvent,
|
||||
SessionCompanionExchange,
|
||||
SessionObserverDigest,
|
||||
SessionObserverHealth,
|
||||
SessionObserverPlanProgress,
|
||||
@@ -1913,6 +1931,12 @@ export type {
|
||||
SessionVisibility,
|
||||
SessionVisibilitySetParams,
|
||||
SessionVisibilitySetResult,
|
||||
SessionsCompanionAskParams,
|
||||
SessionsCompanionAskResult,
|
||||
SessionsCompanionResetParams,
|
||||
SessionsCompanionResetResult,
|
||||
SessionsCompanionStateParams,
|
||||
SessionsCompanionStateResult,
|
||||
SessionsObserverAskParams,
|
||||
SessionsObserverAskResult,
|
||||
SessionsObserverVisibilityParams,
|
||||
|
||||
@@ -554,7 +554,14 @@ import {
|
||||
SessionObserverDigestSchema,
|
||||
SessionObserverHealthSchema,
|
||||
SessionObserverPlanProgressSchema,
|
||||
SessionCompanionExchangeSchema,
|
||||
SessionRowSchema,
|
||||
SessionsCompanionAskParamsSchema,
|
||||
SessionsCompanionAskResultSchema,
|
||||
SessionsCompanionResetParamsSchema,
|
||||
SessionsCompanionResetResultSchema,
|
||||
SessionsCompanionStateParamsSchema,
|
||||
SessionsCompanionStateResultSchema,
|
||||
SessionsObserverAskParamsSchema,
|
||||
SessionsObserverAskResultSchema,
|
||||
SessionsObserverVisibilityParamsSchema,
|
||||
@@ -854,7 +861,14 @@ export const ProtocolSchemas = {
|
||||
SessionObserverHealth: SessionObserverHealthSchema,
|
||||
SessionObserverPlanProgress: SessionObserverPlanProgressSchema,
|
||||
SessionObserverDigest: SessionObserverDigestSchema,
|
||||
SessionCompanionExchange: SessionCompanionExchangeSchema,
|
||||
SessionRow: SessionRowSchema,
|
||||
SessionsCompanionAskParams: SessionsCompanionAskParamsSchema,
|
||||
SessionsCompanionAskResult: SessionsCompanionAskResultSchema,
|
||||
SessionsCompanionResetParams: SessionsCompanionResetParamsSchema,
|
||||
SessionsCompanionResetResult: SessionsCompanionResetResultSchema,
|
||||
SessionsCompanionStateParams: SessionsCompanionStateParamsSchema,
|
||||
SessionsCompanionStateResult: SessionsCompanionStateResultSchema,
|
||||
SessionsObserverAskParams: SessionsObserverAskParamsSchema,
|
||||
SessionsObserverAskResult: SessionsObserverAskResultSchema,
|
||||
SessionsObserverVisibilityParams: SessionsObserverVisibilityParamsSchema,
|
||||
|
||||
@@ -77,6 +77,45 @@ export const SessionsObserverVisibilityResultSchema = closedObject({
|
||||
ok: Type.Literal(true),
|
||||
});
|
||||
|
||||
/** One bounded question/answer exchange in the ephemeral session companion. */
|
||||
export const SessionCompanionExchangeSchema = closedObject({
|
||||
question: Type.String({ minLength: 1, maxLength: 400 }),
|
||||
answer: Type.String({ minLength: 1, maxLength: 1200 }),
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
|
||||
/** Asks the read-only companion about one session and its workspace. */
|
||||
export const SessionsCompanionAskParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
question: Type.String({ minLength: 1, maxLength: 400 }),
|
||||
});
|
||||
|
||||
/** Companion answer returned only to the requesting operator. */
|
||||
export const SessionsCompanionAskResultSchema = closedObject({
|
||||
answer: Type.String({ minLength: 1, maxLength: 1200 }),
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
|
||||
/** Selects the in-memory companion thread for one session. */
|
||||
export const SessionsCompanionStateParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
});
|
||||
|
||||
/** Current bounded exchanges for one session companion thread. */
|
||||
export const SessionsCompanionStateResultSchema = closedObject({
|
||||
exchanges: Type.Array(SessionCompanionExchangeSchema, { maxItems: 24 }),
|
||||
});
|
||||
|
||||
/** Selects the in-memory companion thread to clear. */
|
||||
export const SessionsCompanionResetParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
});
|
||||
|
||||
/** Acknowledges clearing one companion thread. */
|
||||
export const SessionsCompanionResetResultSchema = closedObject({
|
||||
ok: Type.Literal(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Session protocol schemas.
|
||||
*
|
||||
@@ -774,6 +813,13 @@ export type SessionsObserverVisibilityParams = Static<
|
||||
export type SessionsObserverVisibilityResult = Static<
|
||||
typeof SessionsObserverVisibilityResultSchema
|
||||
>;
|
||||
export type SessionCompanionExchange = Static<typeof SessionCompanionExchangeSchema>;
|
||||
export type SessionsCompanionAskParams = Static<typeof SessionsCompanionAskParamsSchema>;
|
||||
export type SessionsCompanionAskResult = Static<typeof SessionsCompanionAskResultSchema>;
|
||||
export type SessionsCompanionStateParams = Static<typeof SessionsCompanionStateParamsSchema>;
|
||||
export type SessionsCompanionStateResult = Static<typeof SessionsCompanionStateResultSchema>;
|
||||
export type SessionsCompanionResetParams = Static<typeof SessionsCompanionResetParamsSchema>;
|
||||
export type SessionsCompanionResetResult = Static<typeof SessionsCompanionResetResultSchema>;
|
||||
export type SessionsCompactionListParams = Static<typeof SessionsCompactionListParamsSchema>;
|
||||
export type SessionsCompactionGetParams = Static<typeof SessionsCompactionGetParamsSchema>;
|
||||
export type SessionsCompactionBranchParams = Static<typeof SessionsCompactionBranchParamsSchema>;
|
||||
|
||||
@@ -36,6 +36,8 @@ function prepareCatalogExecutor(
|
||||
yieldDetected: boolean;
|
||||
};
|
||||
runAbortController?: AbortController;
|
||||
sandboxSessionKey?: string;
|
||||
sessionKey?: string;
|
||||
},
|
||||
) {
|
||||
const runAbortController = options?.runAbortController ?? new AbortController();
|
||||
@@ -43,7 +45,7 @@ function prepareCatalogExecutor(
|
||||
attempt: {
|
||||
runId: "run-output-schema",
|
||||
sessionId: "session-output-schema",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: options?.sessionKey ?? "agent:main:main",
|
||||
} as never,
|
||||
activeSession: { agent: {}, isStreaming: false } as never,
|
||||
hookRunner: undefined as never,
|
||||
@@ -67,7 +69,7 @@ function prepareCatalogExecutor(
|
||||
markSourceReplyDelivered: vi.fn(),
|
||||
onBlockReply: vi.fn(),
|
||||
onBlockReplyFlush: vi.fn(),
|
||||
sandboxSessionKey: "agent:main:main",
|
||||
sandboxSessionKey: options?.sandboxSessionKey ?? "agent:main:main",
|
||||
builtinToolNames: new Set(),
|
||||
replaySafeToolNames: new Set(),
|
||||
});
|
||||
@@ -84,6 +86,19 @@ describe("prepareEmbeddedAttemptStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes live events to the transcript session instead of the sandbox authority session", () => {
|
||||
prepareCatalogExecutor([], {
|
||||
sessionKey: "agent:main:internal-session-effects:companion-run",
|
||||
sandboxSessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(mocks.buildSubscriptionParams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:internal-session-effects:companion-run",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates hidden tool results before queuing transcript projections", async () => {
|
||||
const projections: ToolSearchTargetTranscriptProjection[] = [];
|
||||
const rawResult = {
|
||||
|
||||
@@ -271,7 +271,9 @@ export function prepareEmbeddedAttemptStream(input: {
|
||||
silentExpected: attempt.silentExpected,
|
||||
suppressLiveStreamOutput: attempt.suppressLiveStreamOutput,
|
||||
config: attempt.config,
|
||||
sessionKey: input.sandboxSessionKey,
|
||||
// Live events belong to the transcript session. The sandbox key is only
|
||||
// authority context and may intentionally point at a visible parent.
|
||||
sessionKey: attempt.sessionKey,
|
||||
currentChannelId: attempt.currentChannelId,
|
||||
currentMessagingTarget: attempt.currentMessagingTarget,
|
||||
currentThreadId: attempt.currentThreadTs,
|
||||
|
||||
@@ -25,4 +25,16 @@ describe("resolveSessionBoundaryPromptCacheKey", () => {
|
||||
}),
|
||||
).toBe("caller-key");
|
||||
});
|
||||
|
||||
it("clamps derived keys from long internal session ids to OpenAI's 64-char limit", () => {
|
||||
const longSessionId = `internal-session-effects-session-companion-${"a".repeat(50)}`;
|
||||
const key = resolveSessionBoundaryPromptCacheKey({
|
||||
api: "openai-responses",
|
||||
boundaryCount: 0,
|
||||
sessionId: longSessionId,
|
||||
});
|
||||
expect(key).toBeDefined();
|
||||
expect(Array.from(key ?? "").length).toBeLessThanOrEqual(64);
|
||||
expect(key?.startsWith("internal-session-effects-session-companion-")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampOpenAIPromptCacheKey } from "@openclaw/ai/providers";
|
||||
|
||||
export function resolveSessionBoundaryPromptCacheKey(params: {
|
||||
api: string;
|
||||
boundaryCount: number;
|
||||
@@ -12,5 +14,11 @@ export function resolveSessionBoundaryPromptCacheKey(params: {
|
||||
params.api === "openai-completions" ||
|
||||
params.api === "openai-responses" ||
|
||||
params.api.includes("openai");
|
||||
return usesOpenAIPromptCacheKey ? `${params.sessionId}:${params.boundaryCount}` : undefined;
|
||||
if (!usesOpenAIPromptCacheKey) {
|
||||
return undefined;
|
||||
}
|
||||
// Clamp at derivation, not only in provider param builders: proxy runtimes
|
||||
// serialize this key verbatim, and long internal-effects session ids
|
||||
// (companion/btw) otherwise exceed OpenAI's 64-char prompt_cache_key limit.
|
||||
return clampOpenAIPromptCacheKey(`${params.sessionId}:${params.boundaryCount}`);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"device.pair.rename",
|
||||
"sessions.observer.ask",
|
||||
"sessions.observer.visibility",
|
||||
"sessions.companion.ask",
|
||||
"sessions.companion.state",
|
||||
"sessions.companion.reset",
|
||||
"channels.pairing.list",
|
||||
"channels.pairing.approve",
|
||||
"channels.pairing.dismiss",
|
||||
|
||||
@@ -466,6 +466,16 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "session.suggestions.list", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "session.suggestions.resolve", scope: "operator.write", since: "2026.7" },
|
||||
{ name: "session.typing", scope: "operator.write", since: "2026.7" },
|
||||
// Companion state is process-local and its runner is hard-restricted to
|
||||
// read-only workspace and exact-session tools.
|
||||
{ name: "sessions.companion.ask", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "sessions.companion.state", scope: "operator.read", since: "2026.7" },
|
||||
{
|
||||
name: "sessions.companion.reset",
|
||||
scope: "operator.write",
|
||||
since: "2026.7",
|
||||
controlPlaneWrite: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("listGatewayMethods", () => {
|
||||
});
|
||||
|
||||
it("appends new methods after model probing without shifting older method indices", () => {
|
||||
expect(listGatewayMethods().slice(-22)).toEqual([
|
||||
expect(listGatewayMethods().slice(-25)).toEqual([
|
||||
"models.probe",
|
||||
"migrations.memory.plan",
|
||||
"migrations.memory.apply",
|
||||
@@ -85,6 +85,9 @@ describe("listGatewayMethods", () => {
|
||||
"session.suggestions.list",
|
||||
"session.suggestions.resolve",
|
||||
"session.typing",
|
||||
"sessions.companion.ask",
|
||||
"sessions.companion.state",
|
||||
"sessions.companion.reset",
|
||||
]);
|
||||
const methods = listGatewayMethods();
|
||||
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
||||
@@ -145,7 +148,7 @@ describe("listGatewayMethods", () => {
|
||||
"exec.approval.get",
|
||||
]);
|
||||
expect(methods).toContain("tts.speak");
|
||||
expect(coreMethods.slice(-29)).toEqual([
|
||||
expect(coreMethods.slice(-32)).toEqual([
|
||||
"sessions.catalog.continue",
|
||||
"sessions.catalog.archive",
|
||||
"approval.get",
|
||||
@@ -175,6 +178,9 @@ describe("listGatewayMethods", () => {
|
||||
"session.suggestions.list",
|
||||
"session.suggestions.resolve",
|
||||
"session.typing",
|
||||
"sessions.companion.ask",
|
||||
"sessions.companion.state",
|
||||
"sessions.companion.reset",
|
||||
]);
|
||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
||||
|
||||
@@ -241,6 +241,10 @@ const loadSessionObserverHandlers = lazyHandlerModule(
|
||||
() => import("./session-observer-rpc.js"),
|
||||
(module) => module.sessionObserverHandlers,
|
||||
);
|
||||
const loadSessionCompanionHandlers = lazyHandlerModule(
|
||||
() => import("./session-companion-rpc.js"),
|
||||
(module) => module.sessionCompanionHandlers,
|
||||
);
|
||||
const loadSkillsHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/skills.js"),
|
||||
(module) => module.skillsHandlers,
|
||||
@@ -716,6 +720,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
methods: ["sessions.observer.ask", "sessions.observer.visibility"],
|
||||
loadHandlers: loadSessionObserverHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["sessions.companion.ask", "sessions.companion.state", "sessions.companion.reset"],
|
||||
loadHandlers: loadSessionCompanionHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: [
|
||||
"sessions.list",
|
||||
|
||||
@@ -155,6 +155,7 @@ export type GatewayRequestContext = {
|
||||
cron: GatewayCronServiceContract;
|
||||
cronStorePath: string;
|
||||
getRuntimeConfig: () => OpenClawConfig;
|
||||
sessionCompanion?: import("../session-companion.js").SessionCompanionService;
|
||||
sessionObserver?: SessionObserverService;
|
||||
notifyPluginMetadataChanged: () => void;
|
||||
getMcpAppSandboxPort?: () => number | undefined;
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeContextParams(
|
||||
deps: {} as never,
|
||||
runtimeState,
|
||||
getRuntimeConfig: vi.fn(() => config),
|
||||
sessionCompanion: {} as never,
|
||||
sessionObserver: {} as never,
|
||||
resolveTerminalLaunchPolicy: vi.fn(() => ({
|
||||
ok: false as const,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { GatewayServerLiveState } from "./server-live-state.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
|
||||
import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js";
|
||||
import type { SessionCompanionService } from "./session-companion.js";
|
||||
import type { SessionObserverService } from "./session-observer-contract.js";
|
||||
|
||||
type GatewayRequestContextClient = GatewayClient & {
|
||||
@@ -23,6 +24,7 @@ type GatewayRequestContextParams = {
|
||||
deps: GatewayRequestContext["deps"];
|
||||
runtimeState: Pick<GatewayServerLiveState, "cronState" | "configReloader">;
|
||||
getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"];
|
||||
sessionCompanion: SessionCompanionService;
|
||||
sessionObserver: SessionObserverService;
|
||||
getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"];
|
||||
ensureSandboxHostPort?: GatewayRequestContext["ensureSandboxHostPort"];
|
||||
@@ -158,6 +160,7 @@ export function createGatewayRequestContext(
|
||||
return params.runtimeState.cronState.storePath;
|
||||
},
|
||||
getRuntimeConfig: params.getRuntimeConfig,
|
||||
sessionCompanion: params.sessionCompanion,
|
||||
sessionObserver: params.sessionObserver,
|
||||
notifyPluginMetadataChanged: () =>
|
||||
params.runtimeState.configReloader.notifyPluginMetadataChanged(),
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
} from "./server-chat-state.js";
|
||||
import { resolveVisibleActiveSessionRunState } from "./server-methods/session-active-runs.js";
|
||||
import { mapTaskSummary, type TaskEventPayload } from "./server-methods/task-summary.js";
|
||||
import { createSessionCompanion } from "./session-companion.js";
|
||||
import { createSessionObserver } from "./session-observer.js";
|
||||
|
||||
function dispatchEventHandler<TEvent>(params: {
|
||||
@@ -79,6 +80,10 @@ export function startGatewayEventSubscriptions(params: {
|
||||
sessionEventSubscribers: params.sessionEventSubscribers,
|
||||
broadcastToConnIds: params.broadcastToConnIds,
|
||||
});
|
||||
const sessionCompanion = createSessionCompanion({
|
||||
getConfig: getRuntimeConfig,
|
||||
sessionObserver,
|
||||
});
|
||||
const unsubscribePrivateAuditEvents = auditEnabled
|
||||
? onAgentAuditEvent(auditRecorder.record)
|
||||
: undefined;
|
||||
@@ -315,6 +320,7 @@ export function startGatewayEventSubscriptions(params: {
|
||||
});
|
||||
const agentUnsub = async () => {
|
||||
unsubscribeAgentEvents();
|
||||
sessionCompanion.dispose();
|
||||
sessionObserver.dispose();
|
||||
unsubscribePrivateAuditEvents?.();
|
||||
unsubscribeToolAuditEvents?.();
|
||||
@@ -393,6 +399,7 @@ export function startGatewayEventSubscriptions(params: {
|
||||
};
|
||||
|
||||
return {
|
||||
sessionCompanion,
|
||||
sessionObserver,
|
||||
agentUnsub,
|
||||
heartbeatUnsub,
|
||||
|
||||
@@ -1755,9 +1755,8 @@ export async function startGatewayServer(
|
||||
import("./server-runtime-startup-services.js"),
|
||||
]),
|
||||
);
|
||||
const { sessionObserver, ...runtimeSubscriptionUnsubs } = await startupTrace.measure(
|
||||
"runtime.subscriptions",
|
||||
() =>
|
||||
const { sessionCompanion, sessionObserver, ...runtimeSubscriptionUnsubs } =
|
||||
await startupTrace.measure("runtime.subscriptions", () =>
|
||||
startGatewayEventSubscriptions({
|
||||
log,
|
||||
broadcast,
|
||||
@@ -1771,7 +1770,7 @@ export async function startGatewayServer(
|
||||
chatAbortControllers,
|
||||
restartRecoveryCandidates,
|
||||
}),
|
||||
);
|
||||
);
|
||||
Object.assign(runtimeState, runtimeSubscriptionUnsubs);
|
||||
|
||||
const runtimeServices = await startupTrace.measure("runtime.services", () =>
|
||||
@@ -2107,6 +2106,7 @@ export async function startGatewayServer(
|
||||
deps,
|
||||
runtimeState,
|
||||
getRuntimeConfig,
|
||||
sessionCompanion,
|
||||
sessionObserver,
|
||||
getMcpAppSandboxPort,
|
||||
ensureSandboxHostPort,
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { SessionCompanionExchange } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
readBtwTranscriptMessages,
|
||||
resolveBtwSessionTranscriptPath,
|
||||
} from "../agents/btw-transcript.js";
|
||||
import { resolveSimpleCompletionSelectionForAgent } from "../agents/simple-completion-runtime.js";
|
||||
import { extractAssistantText, stripToolMessages } from "../agents/tools/chat-history-text.js";
|
||||
import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js";
|
||||
import { resolveStorePath } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { Message, Usage } from "../llm/types.js";
|
||||
import { redactToolPayloadText } from "../logging/redact.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
buildSessionCompanionRunConfig,
|
||||
SESSION_COMPANION_TOOLS,
|
||||
} from "./session-companion-policy.js";
|
||||
import {
|
||||
trimSessionCompanionExchanges,
|
||||
type SessionCompanionSeedMessage,
|
||||
type SessionCompanionThread,
|
||||
} from "./session-companion-state.js";
|
||||
import type { SessionObserverCompanionSnapshot } from "./session-observer-contract.js";
|
||||
import { loadSessionEntryReadOnly } from "./session-utils.js";
|
||||
|
||||
const companionLog = createSubsystemLogger("gateway/session-companion");
|
||||
|
||||
const ASK_TIMEOUT_MS = 60_000;
|
||||
const ANSWER_MAX_CHARS = 1200;
|
||||
const SEED_MAX_MESSAGES = 40;
|
||||
const SEED_MAX_BYTES = 24 * 1024;
|
||||
const SEED_MESSAGE_MAX_CHARS = 4000;
|
||||
const DELTA_MAX_BYTES = 4 * 1024;
|
||||
const MAX_CONCURRENT_ASKS = 6;
|
||||
const ASK_RATE_WINDOW_MS = 60_000;
|
||||
const MAX_ASKS_PER_RATE_WINDOW = 12;
|
||||
const MAX_ASKS_PER_CONNECTION_RATE_WINDOW = 4;
|
||||
|
||||
type SessionCompanionPromptMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
type SessionCompanionRunParams = {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
modelRef: string;
|
||||
sessionKey: string;
|
||||
workspaceDir: string;
|
||||
systemPrompt: string;
|
||||
messages: SessionCompanionPromptMessage[];
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type SessionCompanionAskDeps = {
|
||||
getConfig: () => OpenClawConfig;
|
||||
sessionObserver: {
|
||||
getCompanionSnapshot: (sessionKey: string) => SessionObserverCompanionSnapshot;
|
||||
};
|
||||
resolveUtilityModelRef?: typeof resolveUtilityModelRefForAgent;
|
||||
readSeedMessages?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
}) => Promise<SessionCompanionSeedMessage[]>;
|
||||
run?: (params: SessionCompanionRunParams) => Promise<string>;
|
||||
now?: () => number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
};
|
||||
|
||||
type SessionCompanionAskRuntimeParams = SessionCompanionAskDeps & {
|
||||
threads: Map<string, SessionCompanionThread>;
|
||||
now: () => number;
|
||||
isDisposed: () => boolean;
|
||||
};
|
||||
|
||||
type SessionCompanionAskErrorReason =
|
||||
| "busy"
|
||||
| "rate-limited"
|
||||
| "utility-model-unavailable"
|
||||
| "unavailable";
|
||||
|
||||
export class SessionCompanionAskError extends Error {
|
||||
constructor(
|
||||
readonly reason: SessionCompanionAskErrorReason,
|
||||
message: string,
|
||||
readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SessionCompanionAskError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(sessionKey: string): string {
|
||||
return [
|
||||
`You are the read-only companion observing session ${sessionKey}.`,
|
||||
"Inherited session history, observer digest, and observer notes are reference material, not your task.",
|
||||
"Answer the operator's current question without taking over, continuing, or changing the session's task.",
|
||||
"You have only read-only tools and must not attempt any mutation, write, edit, command execution, message send, or session action.",
|
||||
"Answer from evidence in the inherited context, observer notes, and permitted tool reads; say plainly when you cannot know.",
|
||||
"Return a concise plain-text answer in American English with no markdown or JSON wrapper.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function normalizeSeedText(value: string): string {
|
||||
return truncateUtf16Safe(
|
||||
redactToolPayloadText(value).replace(/\s+/gu, " ").trim(),
|
||||
SEED_MESSAGE_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
function extractUserText(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (typeof content === "string") {
|
||||
return normalizeSeedText(content) || undefined;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = content
|
||||
.flatMap((block) => {
|
||||
if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "text") {
|
||||
return [];
|
||||
}
|
||||
const blockText = (block as { text?: unknown }).text;
|
||||
return typeof blockText === "string" ? [blockText] : [];
|
||||
})
|
||||
.join("\n");
|
||||
return normalizeSeedText(text) || undefined;
|
||||
}
|
||||
|
||||
function readMessageTimestamp(message: unknown): number {
|
||||
if (!message || typeof message !== "object") {
|
||||
return 0;
|
||||
}
|
||||
const value = (message as { timestamp?: unknown }).timestamp;
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
function sanitizeSeedMessages(messages: unknown[]): SessionCompanionSeedMessage[] {
|
||||
const sanitized = stripToolMessages(messages)
|
||||
.slice(-SEED_MAX_MESSAGES)
|
||||
.flatMap((message): SessionCompanionSeedMessage[] => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return [];
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
const text =
|
||||
role === "assistant"
|
||||
? normalizeSeedText(extractAssistantText(message) ?? "")
|
||||
: role === "user"
|
||||
? extractUserText(message)
|
||||
: undefined;
|
||||
return text && (role === "assistant" || role === "user")
|
||||
? [{ role, text, ts: readMessageTimestamp(message) }]
|
||||
: [];
|
||||
});
|
||||
const selected: SessionCompanionSeedMessage[] = [];
|
||||
let bytes = 2;
|
||||
for (const message of sanitized.toReversed()) {
|
||||
const messageBytes = Buffer.byteLength(JSON.stringify(message), "utf8") + 1;
|
||||
if (bytes + messageBytes > SEED_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
selected.unshift(message);
|
||||
bytes += messageBytes;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function defaultReadSeedMessages(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
}): Promise<SessionCompanionSeedMessage[]> {
|
||||
const loaded = loadSessionEntryReadOnly(params.sessionKey, { agentId: params.agentId });
|
||||
const sessionId = loaded.entry?.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
return [];
|
||||
}
|
||||
const sessionFile = resolveBtwSessionTranscriptPath({
|
||||
sessionId,
|
||||
sessionEntry: loaded.entry,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: loaded.storePath,
|
||||
});
|
||||
if (!sessionFile) {
|
||||
return [];
|
||||
}
|
||||
const messages = await readBtwTranscriptMessages({
|
||||
sessionFile,
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
return sanitizeSeedMessages(messages);
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
function toRunnerHistoryMessage(
|
||||
message: SessionCompanionPromptMessage,
|
||||
selection: { provider: string; modelId: string },
|
||||
): Message {
|
||||
if (message.role === "user") {
|
||||
return { role: "user", content: message.content, timestamp: message.ts };
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: message.content }],
|
||||
api: "openai-responses",
|
||||
provider: selection.provider,
|
||||
model: selection.modelId,
|
||||
usage: EMPTY_USAGE,
|
||||
stopReason: "stop",
|
||||
timestamp: message.ts,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultRun(params: SessionCompanionRunParams): Promise<string> {
|
||||
const selection = resolveSimpleCompletionSelectionForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
modelRef: params.modelRef,
|
||||
useUtilityModel: true,
|
||||
});
|
||||
if (!selection) {
|
||||
throw new Error("No utility model is configured for this session.");
|
||||
}
|
||||
const current = params.messages.at(-1);
|
||||
if (!current || current.role !== "user") {
|
||||
throw new Error("Session companion has no current question.");
|
||||
}
|
||||
const runId = `session-companion-${randomUUID()}`;
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
|
||||
const { prepareInternalSessionEffectsSession, removeInternalSessionEffectsSession } =
|
||||
await import("../agents/internal-session-effects.js");
|
||||
const target = await prepareInternalSessionEffectsSession({
|
||||
agentId: params.agentId,
|
||||
cwd: params.workspaceDir,
|
||||
runId,
|
||||
storePath,
|
||||
});
|
||||
try {
|
||||
const [{ SessionManager }, { runEmbeddedAgent }] = await Promise.all([
|
||||
import("../agents/sessions/index.js"),
|
||||
import("../agents/embedded-agent.js"),
|
||||
]);
|
||||
const sessionManager = SessionManager.open(target.sessionFile);
|
||||
for (const message of params.messages.slice(0, -1)) {
|
||||
sessionManager.appendMessage(toRunnerHistoryMessage(message, selection));
|
||||
}
|
||||
const result = await runEmbeddedAgent({
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey,
|
||||
sessionTarget: target,
|
||||
sandboxSessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
trigger: "manual",
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: params.workspaceDir,
|
||||
config: buildSessionCompanionRunConfig(params.cfg),
|
||||
prompt: current.content,
|
||||
provider: selection.runtimeProvider ?? selection.provider,
|
||||
model: selection.modelId,
|
||||
modelFallbacksOverride: [],
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
authProfileId: selection.profileId,
|
||||
authProfileIdSource: selection.profileId ? "user" : undefined,
|
||||
timeoutMs: ASK_TIMEOUT_MS,
|
||||
runTimeoutOverrideMs: ASK_TIMEOUT_MS,
|
||||
runId,
|
||||
abortSignal: params.signal,
|
||||
extraSystemPrompt: params.systemPrompt,
|
||||
promptMode: "minimal",
|
||||
bootstrapContextMode: "lightweight",
|
||||
toolsAllow: [...SESSION_COMPANION_TOOLS],
|
||||
disableMessageTool: true,
|
||||
disableTrajectory: true,
|
||||
suppressLiveStreamOutput: true,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
oneShotCliRun: true,
|
||||
inputProvenance: { kind: "internal_system", sourceTool: "session-companion" },
|
||||
});
|
||||
return (
|
||||
result.meta.finalAssistantVisibleText ??
|
||||
result.payloads
|
||||
?.filter((payload) => payload.isReasoning !== true && typeof payload.text === "string")
|
||||
.map((payload) => payload.text)
|
||||
.join("") ??
|
||||
""
|
||||
);
|
||||
} finally {
|
||||
await removeInternalSessionEffectsSession(target);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeedMessage(thread: SessionCompanionThread): string {
|
||||
return JSON.stringify({
|
||||
inheritedSessionMessages: thread.seed.messages,
|
||||
observerDigestJson: thread.seed.digestJson,
|
||||
});
|
||||
}
|
||||
|
||||
function selectDeltaNotes(
|
||||
snapshot: SessionObserverCompanionSnapshot,
|
||||
afterSequence: number,
|
||||
): {
|
||||
notes: Array<{ sequence: number; text: string }>;
|
||||
lastSequence: number;
|
||||
} {
|
||||
const candidates = snapshot.notes
|
||||
.filter((note) => note.sequence > afterSequence)
|
||||
.toSorted((left, right) => left.sequence - right.sequence);
|
||||
const selected: Array<{ sequence: number; text: string }> = [];
|
||||
let bytes = 2;
|
||||
for (const note of candidates.toReversed()) {
|
||||
const noteBytes = Buffer.byteLength(JSON.stringify(note), "utf8") + 1;
|
||||
if (bytes + noteBytes > DELTA_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
selected.unshift(note);
|
||||
bytes += noteBytes;
|
||||
}
|
||||
return {
|
||||
notes: selected,
|
||||
lastSequence: candidates.at(-1)?.sequence ?? afterSequence,
|
||||
};
|
||||
}
|
||||
|
||||
function composePromptMessages(params: {
|
||||
thread: SessionCompanionThread;
|
||||
deltaNotes: Array<{ sequence: number; text: string }>;
|
||||
question: string;
|
||||
now: number;
|
||||
}): SessionCompanionPromptMessage[] {
|
||||
const messages: SessionCompanionPromptMessage[] = [
|
||||
{ role: "user", content: buildSeedMessage(params.thread), ts: params.now },
|
||||
];
|
||||
for (const exchange of params.thread.exchanges) {
|
||||
messages.push({ role: "user", content: exchange.question, ts: exchange.ts });
|
||||
messages.push({ role: "assistant", content: exchange.answer, ts: exchange.ts });
|
||||
}
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: JSON.stringify({ observerNotes: params.deltaNotes, question: params.question }),
|
||||
ts: params.now,
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
function sanitizeAnswer(value: string): string {
|
||||
const redacted = redactToolPayloadText(value).trim();
|
||||
return truncateUtf16Safe(redacted, ANSWER_MAX_CHARS);
|
||||
}
|
||||
|
||||
export function createSessionCompanionAskRuntime(params: SessionCompanionAskRuntimeParams) {
|
||||
const resolveUtilityModelRef = params.resolveUtilityModelRef ?? resolveUtilityModelRefForAgent;
|
||||
const readSeedMessages = params.readSeedMessages ?? defaultReadSeedMessages;
|
||||
const run = params.run ?? defaultRun;
|
||||
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
|
||||
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
|
||||
const controllers = new Map<string, AbortController>();
|
||||
const admissions: Array<{ connId: string; admittedAt: number }> = [];
|
||||
|
||||
const ask = async (request: {
|
||||
sessionKey: string;
|
||||
question: string;
|
||||
connId: string;
|
||||
}): Promise<{ answer: string; ts: number }> => {
|
||||
const sessionKey = request.sessionKey.trim();
|
||||
const question = request.question.trim();
|
||||
if (!sessionKey || !question || params.isDisposed()) {
|
||||
throw new SessionCompanionAskError("unavailable", "Session companion is unavailable.");
|
||||
}
|
||||
const existing = params.threads.get(sessionKey);
|
||||
if (existing?.busy || controllers.has(sessionKey)) {
|
||||
throw new SessionCompanionAskError(
|
||||
"busy",
|
||||
"The session companion is answering another question.",
|
||||
);
|
||||
}
|
||||
const admittedAt = params.now();
|
||||
const cutoff = admittedAt - ASK_RATE_WINDOW_MS;
|
||||
while ((admissions[0]?.admittedAt ?? admittedAt) < cutoff) {
|
||||
admissions.shift();
|
||||
}
|
||||
const connectionAdmissions = admissions.filter(
|
||||
(admission) => admission.connId === request.connId,
|
||||
);
|
||||
const globalRetryAfterMs =
|
||||
admissions.length >= MAX_ASKS_PER_RATE_WINDOW
|
||||
? Math.max(1, (admissions[0]?.admittedAt ?? admittedAt) + ASK_RATE_WINDOW_MS - admittedAt)
|
||||
: 0;
|
||||
const connectionRetryAfterMs =
|
||||
connectionAdmissions.length >= MAX_ASKS_PER_CONNECTION_RATE_WINDOW
|
||||
? Math.max(
|
||||
1,
|
||||
(connectionAdmissions[0]?.admittedAt ?? admittedAt) + ASK_RATE_WINDOW_MS - admittedAt,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
controllers.size >= MAX_CONCURRENT_ASKS ||
|
||||
globalRetryAfterMs > 0 ||
|
||||
connectionRetryAfterMs > 0
|
||||
) {
|
||||
throw new SessionCompanionAskError(
|
||||
"rate-limited",
|
||||
"The session companion has reached its question limit. Try again shortly.",
|
||||
Math.max(
|
||||
controllers.size >= MAX_CONCURRENT_ASKS ? ASK_TIMEOUT_MS : 0,
|
||||
globalRetryAfterMs,
|
||||
connectionRetryAfterMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const cfg = params.getConfig();
|
||||
const observerSnapshot = params.sessionObserver.getCompanionSnapshot(sessionKey);
|
||||
const agentId = observerSnapshot.agentId || resolveSessionAgentId({ sessionKey, config: cfg });
|
||||
const utilityModelRef = resolveUtilityModelRef({ cfg, agentId });
|
||||
if (!utilityModelRef) {
|
||||
throw new SessionCompanionAskError(
|
||||
"utility-model-unavailable",
|
||||
"No utility model is configured for this session.",
|
||||
);
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const thread: SessionCompanionThread = existing ?? {
|
||||
exchanges: [],
|
||||
seed: { messages: [], digestJson: "null" },
|
||||
lastNoteSequence: 0,
|
||||
busy: false,
|
||||
lastUsedAt: admittedAt,
|
||||
};
|
||||
const created = !existing;
|
||||
if (created) {
|
||||
params.threads.set(sessionKey, thread);
|
||||
}
|
||||
thread.busy = true;
|
||||
thread.lastUsedAt = admittedAt;
|
||||
admissions.push({ connId: request.connId, admittedAt });
|
||||
const controller = new AbortController();
|
||||
controllers.set(sessionKey, controller);
|
||||
const timeout = setTimeoutFn(() => controller.abort(), ASK_TIMEOUT_MS);
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("session companion ask timed out or was cancelled")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
if (created) {
|
||||
thread.seed = {
|
||||
messages: await readSeedMessages({ cfg, agentId, sessionKey }),
|
||||
digestJson: JSON.stringify(observerSnapshot.digest ?? null),
|
||||
};
|
||||
}
|
||||
const currentSnapshot = params.sessionObserver.getCompanionSnapshot(sessionKey);
|
||||
const delta = selectDeltaNotes(currentSnapshot, thread.lastNoteSequence);
|
||||
const messages = composePromptMessages({
|
||||
thread,
|
||||
deltaNotes: delta.notes,
|
||||
question,
|
||||
now: admittedAt,
|
||||
});
|
||||
const rawAnswer = await Promise.race([
|
||||
run({
|
||||
cfg,
|
||||
agentId,
|
||||
modelRef: utilityModelRef,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
systemPrompt: buildSystemPrompt(sessionKey),
|
||||
messages,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
aborted,
|
||||
]);
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
params.isDisposed() ||
|
||||
params.threads.get(sessionKey) !== thread
|
||||
) {
|
||||
throw new Error("session companion ask is no longer active");
|
||||
}
|
||||
const answer = sanitizeAnswer(rawAnswer);
|
||||
if (!answer) {
|
||||
throw new Error("session companion returned an empty answer");
|
||||
}
|
||||
const ts = params.now();
|
||||
const exchange: SessionCompanionExchange = { question, answer, ts };
|
||||
thread.exchanges.push(exchange);
|
||||
trimSessionCompanionExchanges(thread.exchanges);
|
||||
thread.lastNoteSequence = delta.lastSequence;
|
||||
thread.lastUsedAt = ts;
|
||||
return { answer, ts };
|
||||
} catch (error) {
|
||||
if (created && params.threads.get(sessionKey) === thread && thread.exchanges.length === 0) {
|
||||
params.threads.delete(sessionKey);
|
||||
}
|
||||
companionLog.warn("session companion ask failed", { sessionKey, error });
|
||||
throw new SessionCompanionAskError(
|
||||
"unavailable",
|
||||
"The session companion could not answer right now.",
|
||||
);
|
||||
} finally {
|
||||
clearTimeoutFn(timeout);
|
||||
if (controllers.get(sessionKey) === controller) {
|
||||
controllers.delete(sessionKey);
|
||||
}
|
||||
if (params.threads.get(sessionKey) === thread) {
|
||||
thread.busy = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ask,
|
||||
cancel(sessionKey: string) {
|
||||
controllers.get(sessionKey)?.abort();
|
||||
},
|
||||
dispose() {
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort();
|
||||
}
|
||||
controllers.clear();
|
||||
admissions.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
export const SESSION_COMPANION_TOOLS = ["read", "sessions_history", "sessions_search"] as const;
|
||||
|
||||
export function buildSessionCompanionRunConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const toolSearch = cfg.tools?.toolSearch;
|
||||
const codeMode = cfg.tools?.codeMode;
|
||||
return {
|
||||
...cfg,
|
||||
tools: {
|
||||
...cfg.tools,
|
||||
sessions: { ...cfg.tools?.sessions, visibility: "self" },
|
||||
fs: { ...cfg.tools?.fs, workspaceOnly: true },
|
||||
toolSearch: { ...(typeof toolSearch === "object" ? toolSearch : {}), enabled: false },
|
||||
codeMode: { ...(typeof codeMode === "object" ? codeMode : {}), enabled: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GatewayErrorDetailCodes } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { SessionCompanionAskError } from "./session-companion-ask.js";
|
||||
import { sessionCompanionHandlers } from "./session-companion-rpc.js";
|
||||
|
||||
async function invoke(
|
||||
method: keyof typeof sessionCompanionHandlers,
|
||||
params: unknown,
|
||||
companion: {
|
||||
ask?: ReturnType<typeof vi.fn>;
|
||||
state?: ReturnType<typeof vi.fn>;
|
||||
reset?: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
client: { connId?: string } = { connId: "conn-1" },
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await sessionCompanionHandlers[method]?.({
|
||||
params,
|
||||
client,
|
||||
context: { sessionCompanion: companion },
|
||||
respond,
|
||||
} as never);
|
||||
return respond;
|
||||
}
|
||||
|
||||
describe("session companion RPC", () => {
|
||||
it("dispatches a valid ask and returns its timestamp", async () => {
|
||||
const ask = vi.fn(async () => ({ answer: "It is checking the fix.", ts: 123 }));
|
||||
const respond = await invoke(
|
||||
"sessions.companion.ask",
|
||||
{ sessionKey: "agent:main:main", question: "What is happening?" },
|
||||
{ ask },
|
||||
);
|
||||
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "What is happening?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
answer: "It is checking the fix.",
|
||||
ts: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ sessionKey: "", question: "why" },
|
||||
{ sessionKey: "agent:main:main", question: "" },
|
||||
{ sessionKey: "agent:main:main", question: "why", extra: true },
|
||||
])("rejects invalid ask params %#", async (params) => {
|
||||
const ask = vi.fn();
|
||||
const respond = await invoke("sessions.companion.ask", params, { ask });
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a connected client for asks", async () => {
|
||||
const ask = vi.fn();
|
||||
const respond = await invoke(
|
||||
"sessions.companion.ask",
|
||||
{ sessionKey: "agent:main:main", question: "Why?" },
|
||||
{ ask },
|
||||
{},
|
||||
);
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the typed retryable busy detail", async () => {
|
||||
const ask = vi.fn(async () => {
|
||||
throw new SessionCompanionAskError("busy", "Already answering.");
|
||||
});
|
||||
const respond = await invoke(
|
||||
"sessions.companion.ask",
|
||||
{ sessionKey: "agent:main:main", question: "Why?" },
|
||||
{ ask },
|
||||
);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "UNAVAILABLE",
|
||||
retryable: true,
|
||||
details: { code: GatewayErrorDetailCodes.SESSION_COMPANION_BUSY },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns and validates per-session state", async () => {
|
||||
const state = vi.fn(() => ({
|
||||
exchanges: [{ question: "Why?", answer: "Because.", ts: 10 }],
|
||||
}));
|
||||
const respond = await invoke(
|
||||
"sessions.companion.state",
|
||||
{ sessionKey: "agent:main:main" },
|
||||
{ state },
|
||||
);
|
||||
expect(state).toHaveBeenCalledWith("agent:main:main");
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
exchanges: [{ question: "Why?", answer: "Because.", ts: 10 }],
|
||||
});
|
||||
|
||||
const invalid = await invoke("sessions.companion.state", {}, { state });
|
||||
expect(invalid).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resets and validates one session thread", async () => {
|
||||
const reset = vi.fn();
|
||||
const respond = await invoke(
|
||||
"sessions.companion.reset",
|
||||
{ sessionKey: "agent:main:main" },
|
||||
{ reset },
|
||||
);
|
||||
expect(reset).toHaveBeenCalledWith("agent:main:main");
|
||||
expect(respond).toHaveBeenCalledWith(true, { ok: true });
|
||||
|
||||
const invalid = await invoke(
|
||||
"sessions.companion.reset",
|
||||
{ sessionKey: "agent:main:main", extra: true },
|
||||
{ reset },
|
||||
);
|
||||
expect(invalid).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
GatewayErrorDetailCodes,
|
||||
validateSessionsCompanionAskParams,
|
||||
validateSessionsCompanionResetParams,
|
||||
validateSessionsCompanionStateParams,
|
||||
type SessionsCompanionAskParams,
|
||||
type SessionsCompanionResetParams,
|
||||
type SessionsCompanionStateParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayRequestHandlers } from "./server-methods/types.js";
|
||||
import { SessionCompanionAskError } from "./session-companion-ask.js";
|
||||
|
||||
export const sessionCompanionHandlers: GatewayRequestHandlers = {
|
||||
"sessions.companion.ask": async ({ params, respond, client, context }) => {
|
||||
if (!validateSessionsCompanionAskParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.companion.ask params: ${formatValidationErrors(validateSessionsCompanionAskParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey, question } = params as SessionsCompanionAskParams;
|
||||
if (!question.trim()) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "question must contain non-whitespace text"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!client?.connId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.FORBIDDEN, "Session companion asks require a connected client."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!context.sessionCompanion) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "Session companion is unavailable."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await context.sessionCompanion.ask({
|
||||
sessionKey,
|
||||
question,
|
||||
connId: client.connId,
|
||||
});
|
||||
respond(true, result);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SessionCompanionAskError)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "The session companion could not answer right now."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error.reason === "busy") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, error.message, {
|
||||
details: { code: GatewayErrorDetailCodes.SESSION_COMPANION_BUSY },
|
||||
retryable: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, error.message, {
|
||||
details: { reason: error.reason },
|
||||
retryable: error.reason === "rate-limited",
|
||||
...(error.retryAfterMs ? { retryAfterMs: error.retryAfterMs } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
"sessions.companion.state": ({ params, respond, context }) => {
|
||||
if (!validateSessionsCompanionStateParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.companion.state params: ${formatValidationErrors(validateSessionsCompanionStateParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!context.sessionCompanion) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "Session companion is unavailable."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey } = params as SessionsCompanionStateParams;
|
||||
respond(true, context.sessionCompanion.state(sessionKey));
|
||||
},
|
||||
|
||||
"sessions.companion.reset": ({ params, respond, context }) => {
|
||||
if (!validateSessionsCompanionResetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.companion.reset params: ${formatValidationErrors(validateSessionsCompanionResetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!context.sessionCompanion) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "Session companion is unavailable."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey } = params as SessionsCompanionResetParams;
|
||||
context.sessionCompanion.reset(sessionKey);
|
||||
respond(true, { ok: true });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SessionCompanionExchange } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
|
||||
export type SessionCompanionSeedMessage = {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type SessionCompanionThread = {
|
||||
exchanges: SessionCompanionExchange[];
|
||||
seed: {
|
||||
messages: SessionCompanionSeedMessage[];
|
||||
digestJson: string;
|
||||
};
|
||||
lastNoteSequence: number;
|
||||
busy: boolean;
|
||||
lastUsedAt: number;
|
||||
};
|
||||
|
||||
const SESSION_COMPANION_MAX_EXCHANGES = 24;
|
||||
const SESSION_COMPANION_MAX_EXCHANGE_BYTES = 48 * 1024;
|
||||
|
||||
function exchangeBytes(exchange: SessionCompanionExchange): number {
|
||||
return Buffer.byteLength(exchange.question, "utf8") + Buffer.byteLength(exchange.answer, "utf8");
|
||||
}
|
||||
|
||||
export function trimSessionCompanionExchanges(exchanges: SessionCompanionExchange[]): void {
|
||||
let bytes = exchanges.reduce((total, exchange) => total + exchangeBytes(exchange), 0);
|
||||
// Dropping the oldest exchange intentionally breaks the replay byte prefix;
|
||||
// the count and byte caps take priority once a long-lived thread is bounded.
|
||||
while (
|
||||
exchanges.length > SESSION_COMPANION_MAX_EXCHANGES ||
|
||||
bytes > SESSION_COMPANION_MAX_EXCHANGE_BYTES
|
||||
) {
|
||||
const removed = exchanges.shift();
|
||||
bytes -= removed ? exchangeBytes(removed) : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAgentToAgentPolicy,
|
||||
createSessionVisibilityGuard,
|
||||
} from "../agents/tools/sessions-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { SessionCompanionAskError } from "./session-companion-ask.js";
|
||||
import {
|
||||
buildSessionCompanionRunConfig,
|
||||
SESSION_COMPANION_TOOLS,
|
||||
} from "./session-companion-policy.js";
|
||||
import { trimSessionCompanionExchanges } from "./session-companion-state.js";
|
||||
import { createSessionCompanion } from "./session-companion.js";
|
||||
import type { SessionObserverCompanionSnapshot } from "./session-observer-contract.js";
|
||||
import { notifyGatewaySessionReset } from "./session-reset-notifications.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createHarness(overrides?: {
|
||||
now?: () => number;
|
||||
readSeedMessages?: () => Promise<Array<{ role: "user" | "assistant"; text: string; ts: number }>>;
|
||||
run?: (params: {
|
||||
messages: Array<{ role: "user" | "assistant"; content: string; ts: number }>;
|
||||
systemPrompt: string;
|
||||
}) => Promise<string>;
|
||||
snapshot?: () => SessionObserverCompanionSnapshot;
|
||||
}) {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const readSeedMessages = vi.fn(
|
||||
overrides?.readSeedMessages ??
|
||||
(async () => [{ role: "user" as const, text: "seed question", ts: 1 }]),
|
||||
);
|
||||
const run = vi.fn(overrides?.run ?? (async () => "Evidence says the build is green."));
|
||||
const getCompanionSnapshot = vi.fn(
|
||||
overrides?.snapshot ??
|
||||
(() => ({
|
||||
agentId: "main",
|
||||
digest: {
|
||||
sessionKey: "agent:main:main",
|
||||
revision: 2,
|
||||
updatedAt: 10,
|
||||
headline: "Running tests",
|
||||
health: "on-track" as const,
|
||||
},
|
||||
notes: [{ sequence: 1, text: "Tool: read package.json" }],
|
||||
})),
|
||||
);
|
||||
const service = createSessionCompanion({
|
||||
getConfig: () => cfg,
|
||||
sessionObserver: { getCompanionSnapshot },
|
||||
resolveUtilityModelRef: () => "openai/gpt-5.6-luna",
|
||||
readSeedMessages,
|
||||
run,
|
||||
now: overrides?.now ?? (() => 100),
|
||||
});
|
||||
return { getCompanionSnapshot, readSeedMessages, run, service };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("session companion asks", () => {
|
||||
it("answers with the frozen seed, observer delta, utility model, and read-only prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
|
||||
await expect(
|
||||
harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Why is it reading that file?",
|
||||
connId: "conn-1",
|
||||
}),
|
||||
).resolves.toEqual({ answer: "Evidence says the build is green.", ts: 100 });
|
||||
|
||||
expect(harness.run).toHaveBeenCalledOnce();
|
||||
const call = harness.run.mock.calls[0]?.[0];
|
||||
expect(call?.systemPrompt).toContain("read-only companion observing session agent:main:main");
|
||||
expect(call?.systemPrompt).toContain("must not attempt any mutation");
|
||||
expect(call?.messages).toHaveLength(2);
|
||||
expect(JSON.parse(call?.messages[0]?.content ?? "{}")).toEqual({
|
||||
inheritedSessionMessages: [{ role: "user", text: "seed question", ts: 1 }],
|
||||
observerDigestJson: JSON.stringify({
|
||||
sessionKey: "agent:main:main",
|
||||
revision: 2,
|
||||
updatedAt: 10,
|
||||
headline: "Running tests",
|
||||
health: "on-track",
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(call?.messages[1]?.content ?? "{}")).toEqual({
|
||||
observerNotes: [{ sequence: 1, text: "Tool: read package.json" }],
|
||||
question: "Why is it reading that file?",
|
||||
});
|
||||
expect(harness.service.state("agent:main:main").exchanges).toEqual([
|
||||
{
|
||||
question: "Why is it reading that file?",
|
||||
answer: "Evidence says the build is green.",
|
||||
ts: 100,
|
||||
},
|
||||
]);
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("serializes asks per session with a typed busy error", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = deferred<string>();
|
||||
const harness = createHarness({ run: async () => await pending.promise });
|
||||
const first = harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "First?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.run).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(
|
||||
harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Second?",
|
||||
connId: "conn-2",
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "busy" } satisfies Partial<SessionCompanionAskError>);
|
||||
|
||||
pending.resolve("first answer");
|
||||
await expect(first).resolves.toMatchObject({ answer: "first answer" });
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("enforces the per-connection rate window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await harness.service.ask({
|
||||
sessionKey: `agent:main:session-${index}`,
|
||||
question: `Question ${index}?`,
|
||||
connId: "conn-1",
|
||||
});
|
||||
}
|
||||
await expect(
|
||||
harness.service.ask({
|
||||
sessionKey: "agent:main:session-5",
|
||||
question: "One too many?",
|
||||
connId: "conn-1",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
reason: "rate-limited",
|
||||
retryAfterMs: 60_000,
|
||||
} satisfies Partial<SessionCompanionAskError>);
|
||||
expect(harness.run).toHaveBeenCalledTimes(4);
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("enforces the global rate window across connections", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await harness.service.ask({
|
||||
sessionKey: `agent:main:global-${index}`,
|
||||
question: `Question ${index}?`,
|
||||
connId: `conn-${index}`,
|
||||
});
|
||||
}
|
||||
await expect(
|
||||
harness.service.ask({
|
||||
sessionKey: "agent:main:global-overflow",
|
||||
question: "One too many globally?",
|
||||
connId: "conn-overflow",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
reason: "rate-limited",
|
||||
} satisfies Partial<SessionCompanionAskError>);
|
||||
expect(harness.run).toHaveBeenCalledTimes(12);
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("builds the seed once and advances observer note deltas across asks", async () => {
|
||||
vi.useFakeTimers();
|
||||
let notes = [{ sequence: 1, text: "first note" }];
|
||||
const harness = createHarness({
|
||||
snapshot: () => ({ agentId: "main", notes }),
|
||||
});
|
||||
await harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "First?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
notes = [
|
||||
{ sequence: 1, text: "first note" },
|
||||
{ sequence: 2, text: "second note" },
|
||||
{ sequence: 3, text: "third note" },
|
||||
];
|
||||
await harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Second?",
|
||||
connId: "conn-2",
|
||||
});
|
||||
|
||||
expect(harness.readSeedMessages).toHaveBeenCalledOnce();
|
||||
const secondMessages = harness.run.mock.calls[1]?.[0].messages ?? [];
|
||||
expect(secondMessages.slice(0, 3).map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect(JSON.parse(secondMessages.at(-1)?.content ?? "{}").observerNotes).toEqual([
|
||||
{ sequence: 2, text: "second note" },
|
||||
{ sequence: 3, text: "third note" },
|
||||
]);
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("caps replay by exchange count and UTF-8 bytes", () => {
|
||||
const exchanges = Array.from({ length: 30 }, (_, index) => ({
|
||||
question: `${index}:${"🦞".repeat(400)}`,
|
||||
answer: "🦀".repeat(1200),
|
||||
ts: index,
|
||||
}));
|
||||
trimSessionCompanionExchanges(exchanges);
|
||||
expect(exchanges.length).toBeLessThanOrEqual(24);
|
||||
expect(exchanges.at(-1)?.ts).toBe(29);
|
||||
expect(
|
||||
exchanges.reduce(
|
||||
(bytes, exchange) =>
|
||||
bytes +
|
||||
Buffer.byteLength(exchange.question, "utf8") +
|
||||
Buffer.byteLength(exchange.answer, "utf8"),
|
||||
0,
|
||||
),
|
||||
).toBeLessThanOrEqual(48 * 1024);
|
||||
});
|
||||
|
||||
it("truncates answers without splitting a UTF-16 surrogate pair", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness({ run: async () => "🦞".repeat(601) });
|
||||
const result = await harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Long answer?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
expect(result.answer).toBe("🦞".repeat(600));
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("sweeps idle threads after two hours", async () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const harness = createHarness({ now: () => now });
|
||||
await harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Before idle?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
now = 2 * 60 * 60_000;
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] });
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("reset clears state and cancels an active ask", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = deferred<string>();
|
||||
const harness = createHarness({ run: async () => await pending.promise });
|
||||
const active = harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Still there?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.run).toHaveBeenCalledOnce());
|
||||
harness.service.reset("agent:main:main");
|
||||
await expect(active).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
} satisfies Partial<SessionCompanionAskError>);
|
||||
expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] });
|
||||
harness.service.dispose();
|
||||
});
|
||||
|
||||
it("clears a thread when the committed gateway reset path notifies", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
await harness.service.ask({
|
||||
sessionKey: "agent:main:main",
|
||||
question: "Before reset?",
|
||||
connId: "conn-1",
|
||||
});
|
||||
expect(harness.service.state("agent:main:main").exchanges).toHaveLength(1);
|
||||
|
||||
notifyGatewaySessionReset("agent:main:main");
|
||||
|
||||
expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] });
|
||||
harness.service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session companion tool scope", () => {
|
||||
it("pins session tools to the target session and read to its workspace", async () => {
|
||||
const cfg = buildSessionCompanionRunConfig({
|
||||
tools: { toolSearch: true, codeMode: true },
|
||||
});
|
||||
expect(SESSION_COMPANION_TOOLS).toEqual(["read", "sessions_history", "sessions_search"]);
|
||||
expect(cfg.tools?.fs?.workspaceOnly).toBe(true);
|
||||
expect(cfg.tools?.sessions?.visibility).toBe("self");
|
||||
expect(cfg.tools?.toolSearch).toMatchObject({ enabled: false });
|
||||
expect(cfg.tools?.codeMode).toMatchObject({ enabled: false });
|
||||
|
||||
const guard = await createSessionVisibilityGuard({
|
||||
action: "history",
|
||||
requesterSessionKey: "agent:main:target",
|
||||
visibility: "self",
|
||||
a2aPolicy: createAgentToAgentPolicy(cfg),
|
||||
});
|
||||
expect(guard.check("agent:main:target")).toMatchObject({ allowed: true });
|
||||
expect(guard.check("agent:main:different")).toMatchObject({
|
||||
allowed: false,
|
||||
status: "forbidden",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
SessionsCompanionAskResult,
|
||||
SessionsCompanionStateResult,
|
||||
} from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import {
|
||||
createSessionCompanionAskRuntime,
|
||||
type SessionCompanionAskDeps,
|
||||
} from "./session-companion-ask.js";
|
||||
import type { SessionCompanionThread } from "./session-companion-state.js";
|
||||
import { onGatewaySessionReset } from "./session-reset-notifications.js";
|
||||
|
||||
export type SessionCompanionService = {
|
||||
ask: (params: {
|
||||
sessionKey: string;
|
||||
question: string;
|
||||
connId: string;
|
||||
}) => Promise<SessionsCompanionAskResult>;
|
||||
state: (sessionKey: string) => SessionsCompanionStateResult;
|
||||
reset: (sessionKey: string) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type SessionCompanionDeps = SessionCompanionAskDeps & {
|
||||
setIntervalFn?: typeof setInterval;
|
||||
clearIntervalFn?: typeof clearInterval;
|
||||
};
|
||||
|
||||
const SESSION_COMPANION_IDLE_TTL_MS = 2 * 60 * 60_000;
|
||||
const SESSION_COMPANION_SWEEP_INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
export function createSessionCompanion(deps: SessionCompanionDeps): SessionCompanionService {
|
||||
const now = deps.now ?? Date.now;
|
||||
const setIntervalFn = deps.setIntervalFn ?? setInterval;
|
||||
const clearIntervalFn = deps.clearIntervalFn ?? clearInterval;
|
||||
const threads = new Map<string, SessionCompanionThread>();
|
||||
let disposed = false;
|
||||
const askRuntime = createSessionCompanionAskRuntime({
|
||||
...deps,
|
||||
now,
|
||||
threads,
|
||||
isDisposed: () => disposed,
|
||||
});
|
||||
|
||||
const reset = (sessionKey: string) => {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
askRuntime.cancel(key);
|
||||
threads.delete(key);
|
||||
};
|
||||
|
||||
const sweep = () => {
|
||||
const cutoff = now() - SESSION_COMPANION_IDLE_TTL_MS;
|
||||
for (const [sessionKey, thread] of threads) {
|
||||
if (!thread.busy && thread.lastUsedAt <= cutoff) {
|
||||
reset(sessionKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
const sweepTimer = setIntervalFn(sweep, SESSION_COMPANION_SWEEP_INTERVAL_MS);
|
||||
sweepTimer.unref?.();
|
||||
const unsubscribeReset = onGatewaySessionReset(reset);
|
||||
|
||||
return {
|
||||
ask: askRuntime.ask,
|
||||
state(sessionKey) {
|
||||
const key = sessionKey.trim();
|
||||
const thread = threads.get(key);
|
||||
if (!thread) {
|
||||
return { exchanges: [] };
|
||||
}
|
||||
thread.lastUsedAt = now();
|
||||
return {
|
||||
exchanges: thread.exchanges.map(({ question, answer, ts }) => ({ question, answer, ts })),
|
||||
};
|
||||
},
|
||||
reset,
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
clearIntervalFn(sweepTimer);
|
||||
unsubscribeReset();
|
||||
askRuntime.dispose();
|
||||
threads.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { flushSessionActivityAssistantNote } from "../agents/session-activity-no
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
SessionObserverAskError,
|
||||
type SessionObserverCompanionSnapshot,
|
||||
type SessionObserverService,
|
||||
type SessionObserverSnapshot,
|
||||
} from "./session-observer-contract.js";
|
||||
import {
|
||||
sanitizeSessionObserverModelText,
|
||||
@@ -28,13 +30,6 @@ const ASK_SYSTEM_PROMPT = [
|
||||
"Return only a concise plain-text answer in American English, with no markdown or JSON wrapper.",
|
||||
].join(" ");
|
||||
|
||||
type SessionObserverSnapshot = {
|
||||
agentId: string;
|
||||
runId?: string;
|
||||
digest?: SessionObserverDigest;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
type SessionObserverAskRuntimeParams = {
|
||||
getConfig: SessionObserverDeps["getConfig"];
|
||||
subscribers: SessionObserverDeps["subscribers"];
|
||||
@@ -89,6 +84,19 @@ export function createSessionObserverAskRuntime(params: SessionObserverAskRuntim
|
||||
};
|
||||
};
|
||||
|
||||
const getCompanionSnapshot = (sessionKey: string): SessionObserverCompanionSnapshot => {
|
||||
const snapshot = getSnapshot(sessionKey);
|
||||
const state = params.states.get(sessionKey);
|
||||
if (!state) {
|
||||
return { ...snapshot, notes: [] };
|
||||
}
|
||||
flushSessionActivityAssistantNote(state);
|
||||
return {
|
||||
...snapshot,
|
||||
notes: state.notes.map((note) => ({ sequence: note.sequence, text: note.text })),
|
||||
};
|
||||
};
|
||||
|
||||
const ask: SessionObserverService["ask"] = async (request) => {
|
||||
const sessionKey = request.sessionKey.trim();
|
||||
const question = request.question.trim();
|
||||
@@ -266,6 +274,7 @@ export function createSessionObserverAskRuntime(params: SessionObserverAskRuntim
|
||||
return {
|
||||
ask,
|
||||
getSnapshot,
|
||||
getCompanionSnapshot,
|
||||
dispose() {
|
||||
for (const controller of askControllers.values()) {
|
||||
controller.abort();
|
||||
|
||||
@@ -29,6 +29,17 @@ export class SessionObserverAskError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionObserverSnapshot = {
|
||||
agentId: string;
|
||||
runId?: string;
|
||||
digest?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionObserverDigest;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type SessionObserverCompanionSnapshot = Omit<SessionObserverSnapshot, "notes"> & {
|
||||
notes: Array<{ sequence: number; text: string }>;
|
||||
};
|
||||
|
||||
export type SessionObserverService = {
|
||||
handleEvent: (event: SessionObserverEvent) => void;
|
||||
setConnectionVisibility: (connId: string, visible: boolean) => void;
|
||||
|
||||
@@ -49,7 +49,7 @@ const FINAL_DIGEST_MIN_RUN_MS = 30_000;
|
||||
const MAX_CONCURRENT_MODEL_SESSIONS = 6;
|
||||
|
||||
type SessionObserver = SessionObserverService &
|
||||
Pick<ReturnType<typeof createSessionObserverAskRuntime>, "getSnapshot">;
|
||||
Pick<ReturnType<typeof createSessionObserverAskRuntime>, "getSnapshot" | "getCompanionSnapshot">;
|
||||
|
||||
export function createSessionObserver(deps: SessionObserverDeps): SessionObserver {
|
||||
const now = deps.now ?? Date.now;
|
||||
@@ -669,6 +669,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
|
||||
}
|
||||
},
|
||||
getSnapshot: askRuntime.getSnapshot,
|
||||
getCompanionSnapshot: askRuntime.getCompanionSnapshot,
|
||||
ask: askRuntime.ask,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
type GatewaySessionResetListener = (sessionKey: string) => void;
|
||||
|
||||
const listeners = new Set<GatewaySessionResetListener>();
|
||||
|
||||
/** Subscribes process-local lifecycle services to committed session resets. */
|
||||
export function onGatewaySessionReset(listener: GatewaySessionResetListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Notifies lifecycle-owned in-memory services after the session reset commits. */
|
||||
export function notifyGatewaySessionReset(sessionKey: string): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(sessionKey);
|
||||
} catch {
|
||||
// A process-local cleanup listener must not turn a committed reset into
|
||||
// an apparent failure or prevent the remaining lifecycle owners running.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
noteActiveSessionForShutdown,
|
||||
} from "./active-sessions-shutdown-tracker.js";
|
||||
import { findDirectChildSessionsForParent } from "./session-child-sessions.js";
|
||||
import { notifyGatewaySessionReset } from "./session-reset-notifications.js";
|
||||
import {
|
||||
archiveSessionTranscriptsDetailed,
|
||||
resolveStableSessionEndTranscript,
|
||||
@@ -1233,6 +1234,7 @@ export async function performGatewaySessionReset(params: {
|
||||
};
|
||||
}
|
||||
handleSessionStateSessionDeleted(target.canonicalKey, agentId);
|
||||
notifyGatewaySessionReset(target.canonicalKey);
|
||||
emitGatewaySessionEndPluginHook({
|
||||
cfg,
|
||||
sessionKey: target.canonicalKey,
|
||||
@@ -1511,7 +1513,9 @@ export async function performGatewaySessionReset(params: {
|
||||
const lifecycle: Awaited<ReturnType<typeof resetSessionEntryLifecycle>> =
|
||||
await lifecyclePromise;
|
||||
if (!resetSkipped) {
|
||||
handleSessionStateSessionReset(target.canonicalKey ?? params.key);
|
||||
const resetSessionKey = target.canonicalKey ?? params.key;
|
||||
handleSessionStateSessionReset(resetSessionKey);
|
||||
notifyGatewaySessionReset(resetSessionKey);
|
||||
}
|
||||
const next = lifecycle.nextEntry;
|
||||
const selectedModel = resolveSessionModelRef(cfg, next, target.agentId);
|
||||
|
||||
+30
-22
@@ -4141,35 +4141,43 @@ export const en: TranslationMap = {
|
||||
usingDefault: "Using default from Settings",
|
||||
resetToDefault: "Reset to default ({model})",
|
||||
},
|
||||
sideChat: {
|
||||
title: "Side chat",
|
||||
notSaved: "Not saved to chat history",
|
||||
thinking: "Thinking…",
|
||||
clear: "Clear side chat",
|
||||
close: "Close side chat",
|
||||
followUp: "Follow up…",
|
||||
followUpLabel: "Follow up in side chat",
|
||||
sendFollowUp: "Send follow-up",
|
||||
},
|
||||
observer: {
|
||||
title: "Session observer",
|
||||
expand: "Expand session observer",
|
||||
collapse: "Collapse session observer",
|
||||
hide: "Hide session observer",
|
||||
show: "Show session observer",
|
||||
rail: {
|
||||
title: "Session companion",
|
||||
subtitle: "Ask about this session or its project",
|
||||
expand: "Expand session rail",
|
||||
collapse: "Collapse session rail",
|
||||
hide: "Hide session rail",
|
||||
show: "Show session rail",
|
||||
clear: "Clear companion thread",
|
||||
plan: "Plan",
|
||||
progress: "{completed} of {total}",
|
||||
pullRequests: "Pull requests",
|
||||
running: "Running",
|
||||
checksPassing: "{count} passed",
|
||||
checksFailing: "{count} failed",
|
||||
checksPending: "{count} running",
|
||||
askLabel: "Ask about this session",
|
||||
askPlaceholder: "Why is it doing that?",
|
||||
finished: "Finished {time}",
|
||||
empty: "Ask a read-only question about this session or its project.",
|
||||
askLabel: "Ask the session companion",
|
||||
askPlaceholder: "What should I know?",
|
||||
askSubmit: "Ask",
|
||||
askPending: "Checking the observations…",
|
||||
askBusy: "The observer is already answering a question.",
|
||||
askUnavailable: "The observer cannot answer right now.",
|
||||
askPending: "Checking the session…",
|
||||
askBusy: "The companion is already answering a question.",
|
||||
askUnavailable: "The companion cannot answer right now.",
|
||||
asOf: "as of {time}",
|
||||
health: {
|
||||
"on-track": "On track",
|
||||
grinding: "Working",
|
||||
stuck: "Stuck",
|
||||
"waiting-on-user": "Waiting on you",
|
||||
"wrapping-up": "Wrapping up",
|
||||
done: "Done",
|
||||
failed: "Failed",
|
||||
},
|
||||
},
|
||||
observer: {
|
||||
title: "Session observer",
|
||||
plan: "Plan",
|
||||
progress: "{completed} of {total}",
|
||||
boardCurrentStatus: "Current status",
|
||||
boardTimeline: "Health timeline",
|
||||
boardCurrentRun: "Current run",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCompanionQuestionPrefill,
|
||||
buildMoreDetailsCompanionQuestion,
|
||||
extractCompanionCommandQuestion,
|
||||
} from "./companion-question.ts";
|
||||
|
||||
describe("companion selection questions", () => {
|
||||
it("builds a bounded single-line details question", () => {
|
||||
expect(buildMoreDetailsCompanionQuestion("Let's Encrypt cert\nis valid")).toBe(
|
||||
'Explain "Let\'s Encrypt cert is valid" from this conversation in more detail.',
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a rail composer prefill without changing the main composer", () => {
|
||||
expect(buildCompanionQuestionPrefill("cron scan job")).toBe('Regarding "cron scan job": ');
|
||||
});
|
||||
|
||||
it("extracts both Control UI command aliases", () => {
|
||||
expect(extractCompanionCommandQuestion("/btw what changed?")).toBe("what changed?");
|
||||
expect(extractCompanionCommandQuestion("/side: what changed?")).toBe("what changed?");
|
||||
expect(extractCompanionCommandQuestion("/btw")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
|
||||
const CHAT_SELECTION_SNIPPET_MAX_CHARS = 300;
|
||||
|
||||
function collapseChatSelectionSnippet(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
return truncateUtf16Safe(collapsed, CHAT_SELECTION_SNIPPET_MAX_CHARS);
|
||||
}
|
||||
|
||||
export function buildMoreDetailsCompanionQuestion(selection: string): string | null {
|
||||
const snippet = collapseChatSelectionSnippet(selection);
|
||||
return snippet ? `Explain "${snippet}" from this conversation in more detail.` : null;
|
||||
}
|
||||
|
||||
export function buildCompanionQuestionPrefill(selection: string): string | null {
|
||||
const snippet = collapseChatSelectionSnippet(selection);
|
||||
return snippet ? `Regarding "${snippet}": ` : null;
|
||||
}
|
||||
|
||||
export function extractCompanionCommandQuestion(message: string): string {
|
||||
return message
|
||||
.trim()
|
||||
.replace(/^\/(?:btw|side)(?::\s*|\s+|$)/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMoreDetailsSideCommand,
|
||||
buildSideChatFollowUpCommand,
|
||||
combineSideChatComposerDraft,
|
||||
extractSideQuestionDisplayText,
|
||||
} from "./side-question.ts";
|
||||
|
||||
describe("side question builders", () => {
|
||||
it("builds a single-line /btw command quoting the selection", () => {
|
||||
expect(buildMoreDetailsSideCommand("Let's Encrypt cert\nis valid")).toBe(
|
||||
`/btw Explain "Let's Encrypt cert is valid" from this conversation in more detail.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineSideChatComposerDraft", () => {
|
||||
it("keeps an unsent prose draft as the question part", () => {
|
||||
expect(combineSideChatComposerDraft("cron scan job", "why does this run twice?")).toBe(
|
||||
`/btw Regarding "cron scan job": why does this run twice?`,
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses multiline drafts so the single-line /btw send loses nothing", () => {
|
||||
expect(combineSideChatComposerDraft("cron scan job", "first line\nsecond line")).toBe(
|
||||
`/btw Regarding "cron scan job": first line second line`,
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces slash-command drafts instead of embedding them", () => {
|
||||
expect(combineSideChatComposerDraft("cron scan job", "/compact")).toBe(
|
||||
`/btw Regarding "cron scan job": `,
|
||||
);
|
||||
});
|
||||
|
||||
it("behaves like the plain prefill when the composer is empty", () => {
|
||||
expect(combineSideChatComposerDraft("cron scan job", "")).toBe(
|
||||
`/btw Regarding "cron scan job": `,
|
||||
);
|
||||
expect(combineSideChatComposerDraft("cron scan job", undefined)).toBe(
|
||||
`/btw Regarding "cron scan job": `,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSideChatFollowUpCommand", () => {
|
||||
it("sends a plain /btw when there is no previous turn", () => {
|
||||
expect(buildSideChatFollowUpCommand(null, "what about tests?")).toEqual({
|
||||
command: "/btw what about tests?",
|
||||
question: "what about tests?",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the previous side question and answer as context", () => {
|
||||
expect(
|
||||
buildSideChatFollowUpCommand(
|
||||
{ question: "Is cert A valid?", answer: "No,\nit expired." },
|
||||
"when did it expire?",
|
||||
),
|
||||
).toEqual({
|
||||
command:
|
||||
'/btw Context — the previous side question "Is cert A valid?" was answered: "No, it expired.". Follow-up question: when did it expire?',
|
||||
question: "when did it expire?",
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses multiline questions and rejects empty ones", () => {
|
||||
expect(buildSideChatFollowUpCommand(null, "first\nsecond")?.question).toBe("first second");
|
||||
expect(buildSideChatFollowUpCommand(null, " \n ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSideQuestionDisplayText", () => {
|
||||
it("drops the /btw and /side prefixes", () => {
|
||||
expect(extractSideQuestionDisplayText("/btw what changed?")).toBe("what changed?");
|
||||
expect(extractSideQuestionDisplayText("/side: what changed?")).toBe("what changed?");
|
||||
expect(extractSideQuestionDisplayText("/btw")).toBe("");
|
||||
});
|
||||
|
||||
it("never truncates questions that merely resemble follow-up context", () => {
|
||||
expect(
|
||||
extractSideQuestionDisplayText("/btw Why does this say Follow-up question: pending?"),
|
||||
).toBe("Why does this say Follow-up question: pending?");
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
// Builders for selection-driven /btw side questions (chat selection popup).
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
|
||||
/** Cap quoted selection snippets so the /btw command stays bounded. */
|
||||
const CHAT_SELECTION_SNIPPET_MAX_CHARS = 600;
|
||||
|
||||
/**
|
||||
* /btw questions are single-line: command normalization keeps only the first
|
||||
* line, so newlines in the quoted selection must collapse to spaces before
|
||||
* the snippet is embedded in the command text.
|
||||
*/
|
||||
function collapseChatSelectionSnippet(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
return truncateUtf16Safe(collapsed, CHAT_SELECTION_SNIPPET_MAX_CHARS);
|
||||
}
|
||||
|
||||
/** Implicit "More details" prompt sent immediately as a /btw side question. */
|
||||
export function buildMoreDetailsSideCommand(selection: string): string | null {
|
||||
const snippet = collapseChatSelectionSnippet(selection);
|
||||
if (!snippet) {
|
||||
return null;
|
||||
}
|
||||
return `/btw Explain "${snippet}" from this conversation in more detail.`;
|
||||
}
|
||||
|
||||
/** Composer draft for "Ask in side chat": user types the question after the quote. */
|
||||
function buildSideChatComposerDraft(selection: string): string | null {
|
||||
const snippet = collapseChatSelectionSnippet(selection);
|
||||
if (!snippet) {
|
||||
return null;
|
||||
}
|
||||
return `/btw Regarding "${snippet}": `;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ask in side chat" must never discard an unsent draft: plain prose carries
|
||||
* over as the question part. Drafts that are themselves slash commands cannot
|
||||
* be embedded in a /btw question, so they are replaced instead.
|
||||
*/
|
||||
export function combineSideChatComposerDraft(
|
||||
selection: string,
|
||||
existingDraft: string | undefined,
|
||||
): string | null {
|
||||
const prefill = buildSideChatComposerDraft(selection);
|
||||
if (!prefill) {
|
||||
return null;
|
||||
}
|
||||
// /btw sends only the first line; collapse the carried-over prose so a
|
||||
// multiline draft is not silently truncated at send time.
|
||||
const existing = existingDraft?.replace(/\s+/g, " ").trim() ?? "";
|
||||
if (!existing || existing.startsWith("/")) {
|
||||
return prefill;
|
||||
}
|
||||
return `${prefill}${existing}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detached side answers never enter session history, so a follow-up /btw must
|
||||
* carry its own context: the previous side question and answer ride along
|
||||
* (capped) ahead of the new question. The command text is prompt-only — the
|
||||
* panel displays the returned `question` from structured state and never
|
||||
* parses the command string back apart.
|
||||
*/
|
||||
export function buildSideChatFollowUpCommand(
|
||||
previous: { question: string; answer: string } | null,
|
||||
question: string,
|
||||
): { command: string; question: string } | null {
|
||||
// /btw sends only the first line; collapse so multiline questions are not
|
||||
// silently truncated at send time.
|
||||
const trimmed = question.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const previousQuestion = previous ? collapseChatSelectionSnippet(previous.question) : "";
|
||||
const previousAnswer = previous ? collapseChatSelectionSnippet(previous.answer) : "";
|
||||
if (!previousQuestion && !previousAnswer) {
|
||||
return { command: `/btw ${trimmed}`, question: trimmed };
|
||||
}
|
||||
return {
|
||||
command: `/btw Context — the previous side question "${previousQuestion}" was answered: "${previousAnswer}". Follow-up question: ${trimmed}`,
|
||||
question: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable question for the pending side-chat turn (drops the /btw prefix). */
|
||||
export function extractSideQuestionDisplayText(message: string): string {
|
||||
return message
|
||||
.trim()
|
||||
.replace(/^\/(?:btw|side)(?::\s*|\s+|$)/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { normalizeOptionalString } from "../string-coerce.ts";
|
||||
|
||||
/** Local-only placeholder shown while a sent /btw side question awaits its result. */
|
||||
export type ChatSideResultPending = {
|
||||
question: string;
|
||||
ts: number;
|
||||
/** Detached send run id, set once the send is acked; used to drop the card
|
||||
* when the run terminates without ever emitting a chat.side_result. */
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
export type ChatSideResult = {
|
||||
kind: "btw";
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
question: string;
|
||||
text: string;
|
||||
isError: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops the pending BTW card without consuming its run. The run id is
|
||||
* recorded in the suppression set so late side_result/terminal events from
|
||||
* the abandoned run cannot reach the side-result card or the transcript.
|
||||
*/
|
||||
export function retirePendingChatSideQuestion(state: {
|
||||
chatSideResultPending?: ChatSideResultPending | null;
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
}) {
|
||||
const runId = state.chatSideResultPending?.runId;
|
||||
if (runId) {
|
||||
state.chatSideResultTerminalRuns?.add(runId);
|
||||
}
|
||||
state.chatSideResultPending = null;
|
||||
}
|
||||
|
||||
export function parseChatSideResult(payload: unknown): ChatSideResult | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = payload as Record<string, unknown>;
|
||||
if (candidate.kind !== "btw") {
|
||||
return null;
|
||||
}
|
||||
const runId = normalizeOptionalString(candidate.runId);
|
||||
const sessionKey = normalizeOptionalString(candidate.sessionKey);
|
||||
const question = normalizeOptionalString(candidate.question);
|
||||
const text = normalizeOptionalString(candidate.text);
|
||||
if (!(runId && sessionKey && question && text)) {
|
||||
return null;
|
||||
}
|
||||
const agentId = normalizeOptionalString(candidate.agentId);
|
||||
return {
|
||||
kind: "btw",
|
||||
runId,
|
||||
sessionKey,
|
||||
...(agentId ? { agentId } : {}),
|
||||
question,
|
||||
text,
|
||||
isError: candidate.isError === true,
|
||||
ts:
|
||||
typeof candidate.ts === "number" && Number.isFinite(candidate.ts) ? candidate.ts : Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,7 @@
|
||||
// Control UI tests cover chat behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GatewayRequestError } from "../../api/gateway.ts";
|
||||
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
|
||||
import {
|
||||
handleChatGatewayEvent,
|
||||
handleChatSideResultGatewayEvent,
|
||||
type ChatEventPayload,
|
||||
} from "./chat-gateway.ts";
|
||||
import { handleChatGatewayEvent, type ChatEventPayload } from "./chat-gateway.ts";
|
||||
import { loadChatHistory, type ChatState } from "./chat-history.ts";
|
||||
import { readChatMessagesFromCache } from "./session-message-cache.ts";
|
||||
|
||||
@@ -23,8 +18,6 @@ function createState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatRunStartup: null,
|
||||
chatSideChatTurns: [],
|
||||
chatSideResultTerminalRuns: new Set<string>(),
|
||||
chatThinkingLevel: null,
|
||||
chatVerboseLevel: null,
|
||||
client: null,
|
||||
@@ -110,291 +103,25 @@ function createOtherRunNoReplyFinalPayload(): ChatEventPayload {
|
||||
return createOtherRunSilentFinalPayload("NO_REPLY");
|
||||
}
|
||||
|
||||
describe("chat side result gateway events", () => {
|
||||
it("stores BTW side results for the active session", () => {
|
||||
const state = createState();
|
||||
|
||||
expect(
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "Only the UI layer was missing support.",
|
||||
ts: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(state.chatSideChatTurns).toHaveLength(1);
|
||||
expect(state.chatSideChatTurns?.[0]).toMatchObject({
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "Only the UI layer was missing support.",
|
||||
});
|
||||
expect(state.chatSideResultTerminalRuns?.has("btw-run-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("stores selected-global BTW side results for agent main aliases", () => {
|
||||
const state = createState({
|
||||
sessionKey: "agent:work:main",
|
||||
agentsList: { defaultId: "main" },
|
||||
});
|
||||
|
||||
expect(
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-work-global",
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
question: "what changed?",
|
||||
text: "The alias receives canonical global side results.",
|
||||
ts: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(state.chatSideChatTurns?.[0]).toMatchObject({
|
||||
kind: "btw",
|
||||
runId: "btw-work-global",
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
text: "The alias receives canonical global side results.",
|
||||
});
|
||||
expect(state.chatSideResultTerminalRuns?.has("btw-work-global")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores selected-global BTW side results from another agent", () => {
|
||||
const state = createState({
|
||||
sessionKey: "global",
|
||||
assistantAgentId: "work",
|
||||
agentsList: { defaultId: "main" },
|
||||
});
|
||||
|
||||
expect(
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-main-global",
|
||||
sessionKey: "global",
|
||||
agentId: "main",
|
||||
question: "what changed?",
|
||||
text: "This belongs to another selected agent.",
|
||||
ts: 123,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(state.chatSideChatTurns).toEqual([]);
|
||||
expect(state.chatSideResultTerminalRuns?.has("btw-main-global")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the pending side question when its result arrives", () => {
|
||||
const state = createState();
|
||||
state.chatSideResultPending = { question: "what changed?", ts: 1, runId: "btw-run-1" };
|
||||
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "Answer.",
|
||||
ts: 123,
|
||||
});
|
||||
|
||||
expect(state.chatSideResultPending).toBeNull();
|
||||
expect(state.chatSideChatTurns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accumulates follow-up answers as turns and reopens a hidden panel", () => {
|
||||
const state = createState();
|
||||
state.chatSideChatTurns = [
|
||||
{
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "First answer.",
|
||||
isError: false,
|
||||
ts: 123,
|
||||
},
|
||||
];
|
||||
state.chatSideChatHidden = true;
|
||||
state.chatSideResultPending = { question: "and why?", ts: 2, runId: "btw-run-2" };
|
||||
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-run-2",
|
||||
sessionKey: "main",
|
||||
// Follow-up commands embed prior-turn context; the server echoes the
|
||||
// whole blob back as the question.
|
||||
question:
|
||||
'Context — the previous side question "what changed?" was answered: "First answer." Follow-up question: and why?',
|
||||
text: "Second answer.",
|
||||
ts: 124,
|
||||
});
|
||||
|
||||
expect(state.chatSideChatTurns).toHaveLength(2);
|
||||
// The correlated pending record supplies the user's typed question.
|
||||
expect(state.chatSideChatTurns?.[1]).toMatchObject({
|
||||
runId: "btw-run-2",
|
||||
question: "and why?",
|
||||
text: "Second answer.",
|
||||
});
|
||||
expect(state.chatSideResultPending).toBeNull();
|
||||
// An arriving answer reopens a panel hidden via X/Escape.
|
||||
expect(state.chatSideChatHidden).toBe(false);
|
||||
});
|
||||
|
||||
it("converts a resultless terminal BTW run into an error turn and swallows the event", () => {
|
||||
const state = createState();
|
||||
state.chatSideResultPending = { question: "what changed?", ts: 1, runId: "btw-run-3" };
|
||||
|
||||
const result = handleChatGatewayEvent(state, {
|
||||
runId: "btw-run-3",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "⚠️ /btw requires an active session with existing context." },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(state.chatSideResultPending).toBeNull();
|
||||
expect(state.chatSideChatTurns?.[0]).toMatchObject({
|
||||
kind: "btw",
|
||||
runId: "btw-run-3",
|
||||
question: "what changed?",
|
||||
text: "⚠️ /btw requires an active session with existing context.",
|
||||
isError: true,
|
||||
});
|
||||
// Swallowed: the detached failure must not be adopted into the transcript.
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores side results from retired (superseded or dismissed) runs", () => {
|
||||
const state = createState();
|
||||
// A newer question retired the old pending run before its result arrived.
|
||||
state.chatSideResultPending = { question: "older question", ts: 1, runId: "btw-run-old" };
|
||||
retirePendingChatSideQuestion(state);
|
||||
state.chatSideResultPending = { question: "newer question", ts: 2, runId: "btw-run-new" };
|
||||
|
||||
expect(
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-run-old",
|
||||
sessionKey: "main",
|
||||
question: "older question",
|
||||
text: "Stale answer.",
|
||||
ts: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(state.chatSideChatTurns).toEqual([]);
|
||||
expect(state.chatSideResultPending).toMatchObject({ runId: "btw-run-new" });
|
||||
// The entry stays so the retired run's terminal chat event is swallowed too.
|
||||
expect(state.chatSideResultTerminalRuns?.has("btw-run-old")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps this pane's pending card when another run's result arrives", () => {
|
||||
const state = createState();
|
||||
state.chatSideResultPending = { question: "my question", ts: 1, runId: "btw-run-mine" };
|
||||
|
||||
// Same session, different run (e.g. a split pane) that was never retired
|
||||
// here: it must not replace the live pending card, but its terminal chat
|
||||
// event must still be swallowed in this pane.
|
||||
expect(
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-run-other-pane",
|
||||
sessionKey: "main",
|
||||
question: "other pane question",
|
||||
text: "Other pane answer.",
|
||||
ts: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(state.chatSideChatTurns).toEqual([]);
|
||||
expect(state.chatSideResultPending).toMatchObject({ runId: "btw-run-mine" });
|
||||
expect(state.chatSideResultTerminalRuns?.has("btw-run-other-pane")).toBe(true);
|
||||
|
||||
// This pane's own run still resolves its pending card.
|
||||
handleChatSideResultGatewayEvent(state, {
|
||||
kind: "btw",
|
||||
runId: "btw-run-mine",
|
||||
sessionKey: "main",
|
||||
question: "my question",
|
||||
text: "My answer.",
|
||||
ts: 124,
|
||||
});
|
||||
expect(state.chatSideChatTurns?.at(-1)).toMatchObject({ runId: "btw-run-mine" });
|
||||
expect(state.chatSideResultPending).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a dismissed pending run's terminal reply out of the transcript", () => {
|
||||
const state = createState();
|
||||
state.chatSideResultPending = { question: "dismissed question", ts: 1, runId: "btw-run-5" };
|
||||
retirePendingChatSideQuestion(state);
|
||||
expect(state.chatSideResultPending).toBeNull();
|
||||
|
||||
const result = handleChatGatewayEvent(state, {
|
||||
runId: "btw-run-5",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Late reply." }] },
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
expect(state.chatSideChatTurns).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the pending side question when an unrelated run terminates", () => {
|
||||
const state = createState();
|
||||
state.chatSideResultPending = { question: "what changed?", ts: 1, runId: "btw-run-4" };
|
||||
|
||||
handleChatGatewayEvent(state, {
|
||||
runId: "main-run-9",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
});
|
||||
|
||||
expect(state.chatSideResultPending).toMatchObject({ runId: "btw-run-4" });
|
||||
});
|
||||
|
||||
it("ignores tracked BTW terminal events without touching the active run", () => {
|
||||
const state = createState({
|
||||
chatRunId: "main-run-1",
|
||||
chatStream: "still streaming",
|
||||
chatMessages: [{ role: "assistant", content: [{ type: "text", text: "existing" }] }],
|
||||
});
|
||||
state.chatSideResultTerminalRuns?.add("btw-run-2");
|
||||
|
||||
expect(
|
||||
handleChatGatewayEvent(state, {
|
||||
runId: "btw-run-2",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
}),
|
||||
).toBe(null);
|
||||
|
||||
expect(state.chatSideResultTerminalRuns?.has("btw-run-2")).toBe(false);
|
||||
expect(state.chatRunId).toBe("main-run-1");
|
||||
expect(state.chatStream).toBe("still streaming");
|
||||
expect(state.chatMessages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "text", text: "existing" }] },
|
||||
]);
|
||||
expect(state.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleChatGatewayEvent", () => {
|
||||
it("returns null when payload is missing", () => {
|
||||
const state = createState();
|
||||
expect(handleChatGatewayEvent(state, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("drops sessionless run-idless terminal events instead of materializing them", () => {
|
||||
// Companion/internal runs can surface unkeyed terminal events; with no
|
||||
// active run, undefined === undefined must not pass the run-id fallback.
|
||||
const state = createState({ sessionKey: "main" });
|
||||
const before = state.chatMessages.length;
|
||||
handleChatGatewayEvent(state, {
|
||||
state: "final",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "leaked companion answer" }] },
|
||||
} as ChatEventPayload);
|
||||
expect(state.chatMessages.length).toBe(before);
|
||||
expect(JSON.stringify(state.chatMessages)).not.toContain("leaked companion answer");
|
||||
});
|
||||
|
||||
it("adopts startup status only for the queued local run before its ACK", () => {
|
||||
const state = createState({
|
||||
chatQueue: [
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isAssistantHeartbeatAckForDisplay } from "../../lib/chat/heartbeat-display.ts";
|
||||
import { extractText } from "../../lib/chat/message-extract.ts";
|
||||
import { parseChatSideResult, type ChatSideResult } from "../../lib/chat/side-result.ts";
|
||||
// Control UI page module reconciles Chat Gateway events into Chat state.
|
||||
import { isUiGlobalSessionKey, resolveUiDefaultAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
|
||||
@@ -387,43 +386,16 @@ function handleChatEvent(
|
||||
}
|
||||
|
||||
export function handleChatGatewayEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
// A BTW run that fails before seeding context terminates with a plain chat
|
||||
// event and never emits chat.side_result. Convert the failure into an error
|
||||
// side-result card and swallow the event: detached BTW runs must not reach
|
||||
// normal chat handling, where an idle pane would adopt them into the
|
||||
// transcript. Successful runs clear pending via the side_result handler
|
||||
// before their terminal chat event arrives.
|
||||
if (
|
||||
isTerminalChatState(payload?.state) &&
|
||||
typeof payload?.runId === "string" &&
|
||||
state.chatSideResultPending?.runId === payload.runId
|
||||
) {
|
||||
appendChatSideChatTurn(state, {
|
||||
kind: "btw",
|
||||
runId: payload.runId,
|
||||
sessionKey: payload.sessionKey ?? state.sessionKey,
|
||||
question: state.chatSideResultPending.question,
|
||||
text: extractBtwFailureText(payload) ?? "The side question ended without a result.",
|
||||
isError: true,
|
||||
ts: Date.now(),
|
||||
});
|
||||
state.chatSideResultPending = null;
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isTerminalChatState(payload?.state) &&
|
||||
typeof payload?.runId === "string" &&
|
||||
state.chatSideResultTerminalRuns?.has(payload.runId) === true
|
||||
) {
|
||||
state.chatSideResultTerminalRuns.delete(payload.runId);
|
||||
return null;
|
||||
}
|
||||
const activeRunIdBeforeEvent = state.chatRunId;
|
||||
let terminalKeyedStreamStartIndex: number | undefined;
|
||||
if (
|
||||
isTerminalChatState(payload?.state) &&
|
||||
payload !== undefined &&
|
||||
(chatEventSessionMatches(state, payload) || payload.runId === activeRunIdBeforeEvent) &&
|
||||
// Unkeyed events must also carry a real run id: with no active run,
|
||||
// `undefined === undefined` would let sessionless internal-run terminals
|
||||
// (e.g. companion answers) materialize into the open main thread.
|
||||
(chatEventSessionMatches(state, payload) ||
|
||||
(typeof payload.runId === "string" && payload.runId === activeRunIdBeforeEvent)) &&
|
||||
!isEventForDifferentActiveRun(payload, activeRunIdBeforeEvent)
|
||||
) {
|
||||
// The active stream belongs to the user boundary that preceded any steer
|
||||
@@ -442,62 +414,3 @@ export function handleChatGatewayEvent(state: ChatState, payload?: ChatEventPayl
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function handleChatSideResultGatewayEvent(state: ChatState, payload: unknown): boolean {
|
||||
const sideResult = parseChatSideResult(payload);
|
||||
if (!sideResult) {
|
||||
return false;
|
||||
}
|
||||
if (!chatScopedEventSessionMatches(state, sideResult.sessionKey, sideResult.agentId)) {
|
||||
return false;
|
||||
}
|
||||
// Runs retired before display (superseded by a newer question or dismissed)
|
||||
// enter chatSideResultTerminalRuns via retirePendingChatSideQuestion before
|
||||
// their side_result can arrive; live runs only enter the set below. A
|
||||
// retired run's late result must not replace the current card, and its
|
||||
// entry stays so the trailing terminal chat event is still swallowed.
|
||||
if (state.chatSideResultTerminalRuns?.has(sideResult.runId)) {
|
||||
return true;
|
||||
}
|
||||
// A same-session result from another run (e.g. a split pane) must not
|
||||
// replace this pane's live pending card — displaying it would also let the
|
||||
// dismiss button retire the hidden pending run. Record it so its trailing
|
||||
// terminal chat event is still swallowed here.
|
||||
const pending = state.chatSideResultPending;
|
||||
if (pending?.runId && pending.runId !== sideResult.runId) {
|
||||
state.chatSideResultTerminalRuns?.add(sideResult.runId);
|
||||
return true;
|
||||
}
|
||||
// Follow-up commands embed prior-turn context; the server echoes that whole
|
||||
// blob as the question. The correlated pending record holds the user's
|
||||
// typed question, so prefer it for display.
|
||||
const question = pending?.runId === sideResult.runId ? pending.question : sideResult.question;
|
||||
appendChatSideChatTurn(state, { ...sideResult, question });
|
||||
state.chatSideResultPending = null;
|
||||
state.chatSideResultTerminalRuns?.add(sideResult.runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** An arriving answer appends a turn and reopens a panel hidden via X/Escape. */
|
||||
function appendChatSideChatTurn(state: ChatState, turn: ChatSideResult) {
|
||||
state.chatSideChatTurns = [...(state.chatSideChatTurns ?? []), turn];
|
||||
state.chatSideChatHidden = false;
|
||||
}
|
||||
|
||||
function extractBtwFailureText(payload: ChatEventPayload): string | null {
|
||||
if (typeof payload.errorMessage === "string" && payload.errorMessage.trim()) {
|
||||
return payload.errorMessage;
|
||||
}
|
||||
const message = payload.message as { content?: unknown } | undefined;
|
||||
const blocks = Array.isArray(message?.content) ? message.content : [];
|
||||
const text = blocks
|
||||
.map((block) =>
|
||||
block && typeof block === "object" && (block as { type?: unknown }).type === "text"
|
||||
? (block as { text?: unknown }).text
|
||||
: null,
|
||||
)
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.join("\n")
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,6 @@ import {
|
||||
stripHeartbeatTokenForDisplay,
|
||||
} from "../../lib/chat/heartbeat-display.ts";
|
||||
import { extractText, isEmptyUserTextOnlyMessage } from "../../lib/chat/message-extract.ts";
|
||||
import {
|
||||
retirePendingChatSideQuestion,
|
||||
type ChatSideResult,
|
||||
type ChatSideResultPending,
|
||||
} from "../../lib/chat/side-result.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
@@ -304,12 +299,6 @@ export type ChatState = {
|
||||
lastError: string | null;
|
||||
chatError?: string | null;
|
||||
chatRunError?: { summary: string } | null;
|
||||
/** Completed side-chat turns (oldest first); follow-ups accumulate here. */
|
||||
chatSideChatTurns?: ChatSideResult[];
|
||||
chatSideResultPending?: ChatSideResultPending | null;
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
/** Panel closed via X/Escape; conversation kept until cleared or reset. */
|
||||
chatSideChatHidden?: boolean;
|
||||
chatReplyTarget?: unknown;
|
||||
agentsError?: string | null;
|
||||
onAgentsList?: (agentsList: AgentsListResult, client: GatewayBrowserClient) => void;
|
||||
@@ -833,8 +822,6 @@ export async function clearChatHistory(
|
||||
}
|
||||
state.chatMessages = [];
|
||||
state.chatRunError = null;
|
||||
state.chatSideChatTurns = [];
|
||||
state.chatSideChatHidden = false;
|
||||
state.chatReplyTarget = null;
|
||||
reconcileChatRunLifecycle(state, {
|
||||
outcome: hadActiveRun ? "interrupted" : undefined,
|
||||
@@ -844,13 +831,8 @@ export async function clearChatHistory(
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
clearToolStream: true,
|
||||
clearSideResultTerminalRuns: true,
|
||||
clearRunStatus: !hadActiveRun,
|
||||
});
|
||||
// After the suppression-set wipe above: retire (not just drop) a pending
|
||||
// BTW run so its late resultless terminal event cannot re-enter the freshly
|
||||
// cleared transcript.
|
||||
retirePendingChatSideQuestion(state);
|
||||
await loadChatHistory(state);
|
||||
scheduleChatScroll(state);
|
||||
return "completed";
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import { resolveChatPaneObserverRunId } from "../../lib/observer-digest.ts";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import {
|
||||
ChatObserverAskState,
|
||||
ChatObserverHudElement,
|
||||
ChatObserverHudState,
|
||||
type ObserverHudInput,
|
||||
} from "./components/chat-observer-hud.ts";
|
||||
|
||||
function digest(health: SessionObserverDigest["health"] = "on-track"): SessionObserverDigest {
|
||||
return {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
revision: 1,
|
||||
updatedAt: 2_000,
|
||||
headline: "Reviewing the implementation",
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
function input(overrides: Partial<ObserverHudInput> = {}): ObserverHudInput {
|
||||
return {
|
||||
running: true,
|
||||
activeRunId: "run-1",
|
||||
digest: digest(),
|
||||
sideChatOpen: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ChatObserverHudState", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("moves between hidden, pill, and user-expanded card states", () => {
|
||||
const state = new ChatObserverHudState("pill");
|
||||
expect(state.mode(input({ running: false, digest: null }))).toBe("hidden");
|
||||
expect(state.mode(input({ digest: null }))).toBe("hidden");
|
||||
expect(state.mode(input())).toBe("pill");
|
||||
state.expand();
|
||||
expect(state.mode(input())).toBe("card");
|
||||
state.collapse();
|
||||
expect(state.mode(input())).toBe("pill");
|
||||
});
|
||||
|
||||
it("suppresses missing and stale digests while a run is active", () => {
|
||||
const state = new ChatObserverHudState("card");
|
||||
expect(state.mode(input({ digest: null }))).toBe("hidden");
|
||||
expect(state.mode(input({ digest: { ...digest(), runId: undefined } }))).toBe("hidden");
|
||||
expect(state.mode(input({ digest: { ...digest(), runId: "previous-run" } }))).toBe("hidden");
|
||||
expect(state.mode(input())).toBe("card");
|
||||
});
|
||||
|
||||
it("auto-expands a critical run at most once", () => {
|
||||
const state = new ChatObserverHudState("pill");
|
||||
expect(state.mode(input({ digest: digest("stuck") }))).toBe("card");
|
||||
state.collapse();
|
||||
expect(state.mode(input({ digest: digest("waiting-on-user") }))).toBe("pill");
|
||||
});
|
||||
|
||||
it("yields expanded space to side chat without changing the preference", () => {
|
||||
const state = new ChatObserverHudState("card");
|
||||
expect(state.mode(input({ sideChatOpen: true }))).toBe("pill");
|
||||
expect(state.mode(input({ sideChatOpen: false }))).toBe("card");
|
||||
});
|
||||
|
||||
it("keeps a final digest until read, then hides it", () => {
|
||||
const state = new ChatObserverHudState("pill");
|
||||
const finalDigest = digest("done");
|
||||
expect(
|
||||
state.mode(
|
||||
input({ running: false, activeRunId: null, digest: finalDigest, lastReadAt: 1_999 }),
|
||||
),
|
||||
).toBe("pill");
|
||||
expect(
|
||||
state.mode(
|
||||
input({ running: false, activeRunId: null, digest: finalDigest, lastReadAt: 2_000 }),
|
||||
),
|
||||
).toBe("hidden");
|
||||
});
|
||||
|
||||
it("treats off as strict even for critical digests", () => {
|
||||
const state = new ChatObserverHudState("off");
|
||||
expect(state.mode(input({ digest: digest("stuck") }))).toBe("restore");
|
||||
state.show();
|
||||
expect(state.mode(input({ digest: digest("stuck") }))).toBe("card");
|
||||
});
|
||||
|
||||
it("keeps the restore control during a digest-free running chat", () => {
|
||||
const state = new ChatObserverHudState("off");
|
||||
expect(state.mode(input({ digest: null }))).toBe("restore");
|
||||
expect(state.mode(input({ running: false, digest: null }))).toBe("hidden");
|
||||
});
|
||||
|
||||
it("persists the three display preferences under the display key", () => {
|
||||
const state = new ChatObserverHudState("pill");
|
||||
state.expand();
|
||||
expect(localStorage.getItem("openclaw.chat.observerHud.display")).toBe("card");
|
||||
state.collapse();
|
||||
expect(localStorage.getItem("openclaw.chat.observerHud.display")).toBe("pill");
|
||||
state.hide();
|
||||
expect(localStorage.getItem("openclaw.chat.observerHud.display")).toBe("off");
|
||||
expect(localStorage.getItem("openclaw.chat.observerHud.expanded")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("observer hud run identity from row data", () => {
|
||||
it("shows a projected digest when attaching to an already-running session", () => {
|
||||
const projectedDigest = {
|
||||
sessionKey: "agent:main:current",
|
||||
runId: "server-run",
|
||||
revision: 1,
|
||||
updatedAt: 2_000,
|
||||
headline: "Already running",
|
||||
health: "on-track" as const,
|
||||
};
|
||||
const activeRunId = resolveChatPaneObserverRunId({
|
||||
localRunId: null,
|
||||
session: { hasActiveRun: true, activeRunIds: ["server-run"] },
|
||||
digest: projectedDigest,
|
||||
});
|
||||
|
||||
expect(activeRunId).toBe("server-run");
|
||||
expect(
|
||||
new ChatObserverHudState("pill").mode({
|
||||
running: activeRunId !== null,
|
||||
activeRunId,
|
||||
digest: projectedDigest,
|
||||
sideChatOpen: false,
|
||||
}),
|
||||
).toBe("pill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("observer hud auto-expand latch", () => {
|
||||
it("clears the critical-expansion latch when the hud hides", () => {
|
||||
const state = new ChatObserverHudState("pill");
|
||||
const stuck = {
|
||||
sessionKey: "agent:main:s1",
|
||||
runId: "r1",
|
||||
revision: 1,
|
||||
updatedAt: 10,
|
||||
headline: "Stuck on tests",
|
||||
health: "stuck",
|
||||
} as SessionObserverDigest;
|
||||
expect(
|
||||
state.mode({ running: true, activeRunId: "r1", digest: stuck, sideChatOpen: false }),
|
||||
).toBe("card");
|
||||
expect(
|
||||
state.mode({ running: true, activeRunId: "r1", digest: null, sideChatOpen: false }),
|
||||
).toBe("hidden");
|
||||
const benign = { ...stuck, revision: 2, health: "on-track" } as SessionObserverDigest;
|
||||
expect(
|
||||
state.mode({ running: true, activeRunId: "r1", digest: benign, sideChatOpen: false }),
|
||||
).toBe("pill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatObserverHudElement", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function mount(preference: "card" | "pill" | "off" = "pill") {
|
||||
localStorage.setItem("openclaw.chat.observerHud.display", preference);
|
||||
const element = new ChatObserverHudElement();
|
||||
element.sessionKey = "agent:main:run";
|
||||
element.digest = digest();
|
||||
element.running = true;
|
||||
element.activeRunId = "run-1";
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
}
|
||||
|
||||
it("renders only the restore ghost button while off", async () => {
|
||||
const element = await mount("off");
|
||||
|
||||
expect(element.querySelectorAll("button")).toHaveLength(1);
|
||||
expect(element.querySelector(".chat-observer-hud--restore")).not.toBeNull();
|
||||
expect(element.querySelector(".chat-observer-hud__status")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides to off and reports the visibility change", async () => {
|
||||
const element = await mount();
|
||||
const onVisibilityChange = vi.fn();
|
||||
element.onVisibilityChange = onVisibilityChange;
|
||||
await element.updateComplete;
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[aria-label="Hide session observer"]')?.click();
|
||||
await element.updateComplete;
|
||||
|
||||
expect(localStorage.getItem("openclaw.chat.observerHud.display")).toBe("off");
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith(false);
|
||||
expect(element.querySelector(".chat-observer-hud--restore")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores to a pill and reports the visibility change", async () => {
|
||||
const element = await mount("off");
|
||||
const onVisibilityChange = vi.fn();
|
||||
element.onVisibilityChange = onVisibilityChange;
|
||||
await element.updateComplete;
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[aria-label="Show session observer"]')?.click();
|
||||
await element.updateComplete;
|
||||
|
||||
expect(localStorage.getItem("openclaw.chat.observerHud.display")).toBe("pill");
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith(true);
|
||||
expect(element.querySelector(".chat-observer-hud--pill")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the health label in the status badge", async () => {
|
||||
const element = await mount();
|
||||
expect(element.querySelector(".chat-observer-hud__status")?.textContent?.trim()).toBe(
|
||||
"On track",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["pill", "card"] as const)(
|
||||
"renders hide and toggle controls in %s mode",
|
||||
async (mode) => {
|
||||
const element = await mount(mode);
|
||||
|
||||
expect(element.querySelector('[aria-label="Hide session observer"]')).not.toBeNull();
|
||||
expect(
|
||||
element.querySelector(
|
||||
`[aria-label="${mode === "pill" ? "Expand" : "Collapse"} session observer"]`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("ChatObserverAskState", () => {
|
||||
it("moves a submitted question through pending to an answer", async () => {
|
||||
let resolveAnswer!: (value: { answer: string }) => void;
|
||||
const ask = vi.fn(
|
||||
() =>
|
||||
new Promise<{ answer: string }>((resolve) => {
|
||||
resolveAnswer = resolve;
|
||||
}),
|
||||
);
|
||||
const state = new ChatObserverAskState();
|
||||
state.switchSession("agent:main:one");
|
||||
|
||||
const pending = state.submit(" Why is it rerunning that test? ", ask);
|
||||
expect(state.pending).toBe(true);
|
||||
expect(state.exchanges).toEqual([{ question: "Why is it rerunning that test?" }]);
|
||||
expect(ask).toHaveBeenCalledWith("agent:main:one", "Why is it rerunning that test?");
|
||||
|
||||
resolveAnswer({ answer: "It is verifying the same fix against the focused regression." });
|
||||
await pending;
|
||||
expect(state.pending).toBe(false);
|
||||
expect(state.exchanges).toEqual([
|
||||
{
|
||||
question: "Why is it rerunning that test?",
|
||||
answer: "It is verifying the same fix against the focused regression.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps the typed busy error to a muted hint", async () => {
|
||||
const state = new ChatObserverAskState();
|
||||
state.switchSession("agent:main:one");
|
||||
|
||||
await state.submit("Is it stuck?", async () => {
|
||||
throw Object.assign(new Error("session observer busy"), {
|
||||
gatewayCode: "UNAVAILABLE",
|
||||
details: { code: "SESSION_OBSERVER_BUSY" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(state.exchanges).toEqual([{ question: "Is it stuck?", hint: "busy" }]);
|
||||
});
|
||||
|
||||
it("clears the thread on session switch and ignores the old answer", async () => {
|
||||
let resolveAnswer!: (value: { answer: string }) => void;
|
||||
const state = new ChatObserverAskState();
|
||||
state.switchSession("agent:main:one");
|
||||
const pending = state.submit(
|
||||
"What is it doing?",
|
||||
() =>
|
||||
new Promise<{ answer: string }>((resolve) => {
|
||||
resolveAnswer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
state.switchSession("agent:main:two");
|
||||
expect(state.pending).toBe(false);
|
||||
expect(state.exchanges).toEqual([]);
|
||||
resolveAnswer({ answer: "An answer for the previous session." });
|
||||
await pending;
|
||||
expect(state.exchanges).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { requestSessionObserverAnswer, sendSessionObserverVisibility } from "./chat-observer.ts";
|
||||
|
||||
describe("chat observer ask rpc", () => {
|
||||
it("sends the observer ask RPC with the exact session payload", async () => {
|
||||
const request = vi.fn(async () => ({ answer: "It is rerunning a focused regression." }));
|
||||
|
||||
await expect(
|
||||
requestSessionObserverAnswer(
|
||||
{ request } as unknown as Pick<GatewayBrowserClient, "request">,
|
||||
"agent:main:current",
|
||||
"Why is it rerunning that test?",
|
||||
),
|
||||
).resolves.toEqual({ answer: "It is rerunning a focused regression." });
|
||||
expect(request).toHaveBeenCalledWith("sessions.observer.ask", {
|
||||
sessionKey: "agent:main:current",
|
||||
question: "Why is it rerunning that test?",
|
||||
});
|
||||
});
|
||||
});
|
||||
import { sendSessionObserverVisibility } from "./chat-observer.ts";
|
||||
|
||||
describe("chat observer visibility rpc", () => {
|
||||
it("sends the connection visibility declaration", async () => {
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import type {
|
||||
SessionsObserverAskResult,
|
||||
SessionsObserverVisibilityResult,
|
||||
} from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionsObserverVisibilityResult } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
export function requestSessionObserverAnswer(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
sessionKey: string,
|
||||
question: string,
|
||||
): Promise<SessionsObserverAskResult> {
|
||||
return client.request<SessionsObserverAskResult>("sessions.observer.ask", {
|
||||
sessionKey,
|
||||
question,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendSessionObserverVisibility(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
visible: boolean,
|
||||
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
property,
|
||||
litState,
|
||||
createQuestionPromptState,
|
||||
ChatSessionCompanionThreads,
|
||||
listQuestionPrompts,
|
||||
requestSessionObserverAnswer,
|
||||
parseCatalogSessionKey,
|
||||
requestSessionCompanionAnswer,
|
||||
requestSessionCompanionState,
|
||||
resetSessionCompanion,
|
||||
sendSessionObserverVisibility,
|
||||
PollController,
|
||||
SubscriptionsController,
|
||||
@@ -34,6 +38,7 @@ import {
|
||||
type SessionCatalogHost,
|
||||
type SessionCatalogSession,
|
||||
type SessionDiscussionState,
|
||||
type SessionRailMode,
|
||||
type SessionSharingRole,
|
||||
type SessionSuggestion,
|
||||
type SwarmRosterHydrator,
|
||||
@@ -109,15 +114,16 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
||||
} | null = null;
|
||||
@litState() protected boardChatDockSize: BoardChatDockSize = boardChatDockLayout.load();
|
||||
@litState() protected resetConfirmationOpen = false;
|
||||
@litState() protected observerHudReady = customElements.get("openclaw-chat-observer-hud") != null;
|
||||
protected observerHudLoad: Promise<void> | null = null;
|
||||
protected readonly askSessionObserver = (sessionKey: string, question: string) => {
|
||||
const state = this.state;
|
||||
if (!state?.connected || !state.client) {
|
||||
return Promise.reject(new Error("Gateway is disconnected"));
|
||||
}
|
||||
return requestSessionObserverAnswer(state.client, sessionKey, question);
|
||||
};
|
||||
@litState() protected sessionRailReady = customElements.get("openclaw-chat-session-rail") != null;
|
||||
@litState() protected sessionRailMode: SessionRailMode = "hidden";
|
||||
protected sessionRailModeSessionKey = "";
|
||||
protected sessionRailLoad: Promise<void> | null = null;
|
||||
protected sessionRailOpenRequest = 0;
|
||||
protected sessionRailOpenSessionKey = "";
|
||||
protected sessionCompanionHydrationKey = "";
|
||||
protected readonly sessionCompanionThreads = new ChatSessionCompanionThreads(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
protected readonly setSessionObserverVisibility = (visible: boolean) => {
|
||||
const state = this.state;
|
||||
if (state?.connected && state.client) {
|
||||
@@ -125,6 +131,79 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
protected ensureSessionRail() {
|
||||
if (this.sessionRailReady || this.sessionRailLoad) {
|
||||
return;
|
||||
}
|
||||
this.sessionRailLoad = import("./components/chat-session-rail.ts")
|
||||
.then(() => {
|
||||
if (this.isConnected) {
|
||||
this.sessionRailReady = true;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.sessionRailLoad = null;
|
||||
});
|
||||
}
|
||||
|
||||
protected openSessionRail(): void {
|
||||
this.sessionRailOpenSessionKey = this.state?.sessionKey ?? "";
|
||||
this.ensureSessionRail();
|
||||
this.sessionRailOpenRequest += 1;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected readonly submitSessionCompanionQuestion = async (question: string) => {
|
||||
const state = this.state;
|
||||
if (!state || !state.sessionKey) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = state.sessionKey;
|
||||
this.openSessionRail();
|
||||
if (!state.connected || !state.client) {
|
||||
this.sessionCompanionThreads.setDraft(sessionKey, question);
|
||||
return;
|
||||
}
|
||||
await this.sessionCompanionThreads.submit(sessionKey, question, (key, value) =>
|
||||
requestSessionCompanionAnswer(state.client!, key, value),
|
||||
);
|
||||
};
|
||||
|
||||
protected readonly prefillSessionCompanionQuestion = (question: string) => {
|
||||
const sessionKey = this.state?.sessionKey;
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
this.sessionCompanionThreads.setDraft(sessionKey, question);
|
||||
this.openSessionRail();
|
||||
};
|
||||
|
||||
protected hydrateSessionCompanion(sessionKey: string): void {
|
||||
const state = this.state;
|
||||
if (!state?.connected || !state.client || !sessionKey || parseCatalogSessionKey(sessionKey)) {
|
||||
return;
|
||||
}
|
||||
const hydrationKey = `${this.connectionGeneration}\0${sessionKey}`;
|
||||
if (this.sessionCompanionHydrationKey === hydrationKey) {
|
||||
return;
|
||||
}
|
||||
this.sessionCompanionHydrationKey = hydrationKey;
|
||||
this.ensureSessionRail();
|
||||
void this.sessionCompanionThreads.hydrate(sessionKey, (key) =>
|
||||
requestSessionCompanionState(state.client!, key),
|
||||
);
|
||||
}
|
||||
|
||||
protected readonly clearSessionCompanion = async () => {
|
||||
const state = this.state;
|
||||
if (!state?.connected || !state.client || !state.sessionKey) {
|
||||
return;
|
||||
}
|
||||
await this.sessionCompanionThreads
|
||||
.reset(state.sessionKey, (key) => resetSessionCompanion(state.client!, key))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
protected resetConfirmation:
|
||||
| {
|
||||
sessionKey: string;
|
||||
|
||||
@@ -107,7 +107,6 @@ export {
|
||||
resolveControlUiFollowUpMode,
|
||||
resolveControlUiServerQueueMode,
|
||||
} from "../../lib/chat/follow-up-mode.ts";
|
||||
export { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
|
||||
export { copyToClipboard } from "../../lib/clipboard.ts";
|
||||
export { clampText } from "../../lib/format.ts";
|
||||
export {
|
||||
@@ -168,7 +167,7 @@ export {
|
||||
switchChatHistoryBranch,
|
||||
syncSelectedSessionMessageSubscription,
|
||||
} from "./chat-history.ts";
|
||||
export { requestSessionObserverAnswer, sendSessionObserverVisibility } from "./chat-observer.ts";
|
||||
export { sendSessionObserverVisibility } from "./chat-observer.ts";
|
||||
export {
|
||||
applySelectedSessionProjection,
|
||||
dismissChatError,
|
||||
@@ -179,6 +178,12 @@ export { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts";
|
||||
export { dismissRealtimeTalkError } from "./chat-realtime.ts";
|
||||
export { activeChatRunStartupStatus } from "./chat-run-startup.ts";
|
||||
export { flushChatQueueForEvent, retryReconnectableQueuedChatSends } from "./chat-send-actions.ts";
|
||||
export {
|
||||
ChatSessionCompanionThreads,
|
||||
requestSessionCompanionAnswer,
|
||||
requestSessionCompanionState,
|
||||
resetSessionCompanion,
|
||||
} from "./chat-session-companion.ts";
|
||||
export {
|
||||
flushChatQueueAfterIdleSessionReconciliation,
|
||||
switchChatFastMode,
|
||||
@@ -227,6 +232,7 @@ export {
|
||||
listDismissedChatPullRequests,
|
||||
} from "./components/chat-pull-requests.ts";
|
||||
export { renderChatResizableDivider } from "./components/chat-resizable-divider.ts";
|
||||
export type { SessionRailMode } from "./components/chat-session-rail.ts";
|
||||
export {
|
||||
renderChatSessionSharing,
|
||||
type ChatSessionSharingState,
|
||||
|
||||
@@ -246,6 +246,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneReset {
|
||||
pageState.requestUpdate?.();
|
||||
};
|
||||
pageState.refreshSessionPullRequests = (options) => this.refreshSessionPullRequests(options);
|
||||
pageState.openSessionCompanion = (question) => this.submitSessionCompanionQuestion(question);
|
||||
this.state = pageState;
|
||||
if (this.sessionKey) {
|
||||
const initialSessionKey = this.setPaneSessionKey(this.sessionKey);
|
||||
@@ -395,32 +396,20 @@ export abstract class ChatPaneLifecycle extends ChatPaneReset {
|
||||
areUiSessionKeysEquivalent(row.key, this.state?.sessionKey ?? ""),
|
||||
);
|
||||
// Active runs count even without a digest: a hidden observer generates
|
||||
// none, and the HUD module owns the restore control for turning it back on.
|
||||
// none, and the rail module owns the restore control for turning it back on.
|
||||
const observerRunId = resolveChatPaneObserverRunId({
|
||||
localRunId: this.state?.chatRunId ?? null,
|
||||
session: selectedSessionRow,
|
||||
digest: null,
|
||||
});
|
||||
if (this.state?.sessionKey) {
|
||||
this.hydrateSessionCompanion(this.state.sessionKey);
|
||||
}
|
||||
if (this.state?.observerDigest || selectedSessionRow?.observerDigest || observerRunId) {
|
||||
this.ensureObserverHud();
|
||||
this.ensureSessionRail();
|
||||
}
|
||||
}
|
||||
|
||||
protected ensureObserverHud() {
|
||||
if (this.observerHudReady || this.observerHudLoad) {
|
||||
return;
|
||||
}
|
||||
this.observerHudLoad = import("./components/chat-observer-hud.ts")
|
||||
.then(() => {
|
||||
if (this.isConnected) {
|
||||
this.observerHudReady = true;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.observerHudLoad = null;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.boardProviderLifecycleConnected = false;
|
||||
this.releaseBoardProviderLease();
|
||||
@@ -428,6 +417,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneReset {
|
||||
this.paneResizeObserver?.disconnect();
|
||||
this.paneResizeObserver = null;
|
||||
this.connectionGeneration += 1;
|
||||
this.sessionCompanionHydrationKey = "";
|
||||
this.taskSuggestionsRequestVersion += 1;
|
||||
this.taskSuggestions = [];
|
||||
this.taskSuggestionBusyIds.clear();
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
resolveControlUiServerQueueMode,
|
||||
resolveCurrentSelfUser,
|
||||
resolveChatPaneObserverRunId,
|
||||
retirePendingChatSideQuestion,
|
||||
revealSessionWorkspaceFile,
|
||||
scopedAgentParamsForSession,
|
||||
submitQuestionPrompt,
|
||||
@@ -56,6 +55,7 @@ import {
|
||||
import { ChatPaneHeaderRender } from "./chat-pane-header-render.ts";
|
||||
import {
|
||||
DETAIL_SIDEBAR_SIDE_MIN_WIDTH,
|
||||
SESSION_RAIL_DOCK_MIN_WIDTH,
|
||||
WORKSPACE_RAIL_MAX_WIDTH,
|
||||
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
|
||||
} from "./chat-pane-shared.ts";
|
||||
@@ -198,6 +198,11 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
// split; bottom strips do not.
|
||||
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
|
||||
const detailSplitWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
|
||||
const sidebarStacked = detailSplitWidth < DETAIL_SIDEBAR_SIDE_MIN_WIDTH;
|
||||
const chatMainWidth =
|
||||
state.sidebarOpen && !sidebarStacked ? detailSplitWidth * state.splitRatio : detailSplitWidth;
|
||||
const selectedSessionRailMode =
|
||||
this.sessionRailModeSessionKey === state.sessionKey ? this.sessionRailMode : "hidden";
|
||||
const gatewaySnapshot = this.context.gateway.snapshot;
|
||||
const selfUser = resolveCurrentSelfUser({
|
||||
snapshotUser: gatewaySnapshot.selfUser,
|
||||
@@ -232,12 +237,28 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
fallbackStatus: state.fallbackStatus,
|
||||
planStatus: state.planStatus,
|
||||
observerDigest: catalogKey ? null : observerDigest,
|
||||
observerHudReady: !catalogKey && this.observerHudReady,
|
||||
sessionRailReady: !catalogKey && this.sessionRailReady,
|
||||
observerRunId: catalogKey ? null : observerRunId,
|
||||
observerStartedAt: selectedSession?.startedAt ?? state.chatStreamStartedAt ?? undefined,
|
||||
observerLastReadAt: selectedSession?.lastReadAt,
|
||||
onObserverAsk: catalogKey ? undefined : this.askSessionObserver,
|
||||
// Unconditional: catalog chats never render the HUD (observerHudReady is
|
||||
sessionRailCompanion: catalogKey
|
||||
? undefined
|
||||
: this.sessionCompanionThreads.view(state.sessionKey),
|
||||
sessionRailOpenRequest:
|
||||
this.sessionRailOpenSessionKey === state.sessionKey ? this.sessionRailOpenRequest : 0,
|
||||
sessionRailMode: selectedSessionRailMode,
|
||||
sessionRailDocked: !catalogKey && chatMainWidth >= SESSION_RAIL_DOCK_MIN_WIDTH,
|
||||
onSessionRailSubmit: (question) => void this.submitSessionCompanionQuestion(question),
|
||||
onSessionRailDraftChange: (draft) =>
|
||||
this.sessionCompanionThreads.setDraft(state.sessionKey, draft),
|
||||
onSessionRailClear: () => void this.clearSessionCompanion(),
|
||||
onSessionRailModeChange: (mode) => {
|
||||
if (state.sessionKey !== this.sessionRailModeSessionKey || mode !== this.sessionRailMode) {
|
||||
this.sessionRailModeSessionKey = state.sessionKey;
|
||||
this.sessionRailMode = mode;
|
||||
}
|
||||
},
|
||||
// Unconditional: catalog chats never render the rail (sessionRailReady is
|
||||
// forced false), and a hide/show from any surface must reach the gateway.
|
||||
onObserverVisibilityChange: this.setSessionObserverVisibility,
|
||||
gatewayQuestionPrompts: catalogKey || sessionParticipationBlocked ? [] : this.questionPrompts,
|
||||
@@ -255,9 +276,6 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
loading: this.loadingOlder,
|
||||
}
|
||||
: undefined,
|
||||
sideChatTurns: catalogKey ? [] : state.chatSideChatTurns,
|
||||
sideChatPending: catalogKey ? null : state.chatSideResultPending,
|
||||
sideChatHidden: catalogKey ? true : state.chatSideChatHidden,
|
||||
toolMessages: catalogKey ? [] : state.chatToolMessages,
|
||||
streamSegments: catalogKey ? [] : state.chatStreamSegments,
|
||||
stream: catalogKey ? null : state.chatStream,
|
||||
@@ -412,9 +430,6 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
void this.loadCatalogSession(catalogKey, false);
|
||||
return;
|
||||
}
|
||||
state.chatSideChatTurns = [];
|
||||
state.chatSideChatHidden = false;
|
||||
retirePendingChatSideQuestion(state);
|
||||
state.resetToolStream();
|
||||
this.reconcileWaitingApprovalSnapshot();
|
||||
void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false });
|
||||
@@ -473,38 +488,8 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
? undefined
|
||||
: (id) => void state.steerQueuedChatMessage(id),
|
||||
onGoalCommand: (command) => void state.handleSendChat(command),
|
||||
onSideQuestion: (command, displayQuestion, onSendRejected) =>
|
||||
void state.handleSendChat(command, {
|
||||
...(displayQuestion ? { sideQuestionDisplayText: displayQuestion } : {}),
|
||||
...(onSendRejected ? { onSideQuestionSendRejected: onSendRejected } : {}),
|
||||
}),
|
||||
onSideChatClose: () => {
|
||||
// Hide only: a pending run keeps going and its arriving answer (or a
|
||||
// new question) reopens the panel with the conversation intact.
|
||||
state.chatSideChatHidden = true;
|
||||
state.requestUpdate?.();
|
||||
},
|
||||
onSideChatClear: () => {
|
||||
const pendingRunId = state.chatSideResultPending?.runId;
|
||||
state.chatSideChatTurns = [];
|
||||
state.chatSideChatHidden = false;
|
||||
// Retire (not just clear) so a discarded question's still-running
|
||||
// detached run cannot leak its late reply into the transcript.
|
||||
retirePendingChatSideQuestion(state);
|
||||
// Best-effort targeted abort: trash means "stop the pending side
|
||||
// question", not just hide it. The retire above already suppresses
|
||||
// the run's late events, so a failed abort needs no fallback.
|
||||
if (pendingRunId && state.client && state.connected) {
|
||||
state.client
|
||||
.request("chat.abort", {
|
||||
sessionKey: state.sessionKey,
|
||||
...scopedAgentParamsForSession(state, state.sessionKey),
|
||||
runId: pendingRunId,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
state.requestUpdate?.();
|
||||
},
|
||||
onCompanionQuestion: (question) => void this.submitSessionCompanionQuestion(question),
|
||||
onCompanionPrefill: this.prefillSessionCompanionQuestion,
|
||||
replyTarget: state.chatReplyTarget ?? null,
|
||||
onClearReply: () => {
|
||||
state.chatReplyTarget = null;
|
||||
@@ -543,7 +528,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
},
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarStacked: detailSplitWidth < DETAIL_SIDEBAR_SIDE_MIN_WIDTH,
|
||||
sidebarStacked,
|
||||
splitRatio: state.splitRatio,
|
||||
canvasPluginSurfaceUrl: state.hello?.pluginSurfaceUrls?.canvas ?? null,
|
||||
boardProvider: board.provider,
|
||||
|
||||
@@ -121,6 +121,7 @@ export const WORKSPACE_RAIL_MAX_WIDTH = 280;
|
||||
// .chat-main min-width (312) + divider + .chat-sidebar min-width (300) + slack;
|
||||
// below this the detail panel stacks under the thread.
|
||||
export const DETAIL_SIDEBAR_SIDE_MIN_WIDTH = 680;
|
||||
export const SESSION_RAIL_DOCK_MIN_WIDTH = 1080;
|
||||
|
||||
export const NEW_SESSION_ACTIVE_RUN_MESSAGE =
|
||||
"Start a new thread after the active run or queued messages finish.";
|
||||
|
||||
@@ -31,7 +31,7 @@ const FULL_APP_TEST_OPTIONS = {
|
||||
concurrent: false,
|
||||
timeout: 60_000,
|
||||
} as const;
|
||||
const LONG_SIDE_CHAT_BODY = Array.from(
|
||||
const LONG_SESSION_RAIL_BODY = Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `<p>Line ${index + 1}: keep the complete side result readable.</p>`,
|
||||
).join("");
|
||||
@@ -58,7 +58,8 @@ type ControlRect = {
|
||||
type ChatFixtureOptions = {
|
||||
composerAttachment?: boolean;
|
||||
direct?: boolean;
|
||||
sideChatBody?: string;
|
||||
sessionRailBody?: string;
|
||||
sessionRailDocked?: boolean;
|
||||
singleAgent?: boolean;
|
||||
slashMenu?: boolean;
|
||||
};
|
||||
@@ -272,7 +273,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
<main class="content content--chat">
|
||||
<section class="card chat">
|
||||
<div class="chat-split-container">
|
||||
<div class="chat-main" style="flex: 1 1 100%">
|
||||
<div class="chat-main${opts.sessionRailDocked ? " chat-main--rail-docked" : ""}" style="flex: 1 1 100%">
|
||||
<div class="chat-thread${opts.direct ? " chat-thread--direct" : ""}" role="log">
|
||||
<div class="chat-thread-inner">
|
||||
<div class="chat-group user">
|
||||
@@ -303,31 +304,28 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
opts.sideChatBody !== undefined
|
||||
? `<section class="chat-side-chat" role="dialog" aria-label="Side chat">
|
||||
<header class="chat-side-chat__header">
|
||||
<div class="chat-side-chat__heading">
|
||||
<h2 class="chat-side-chat__title">Side chat</h2>
|
||||
<span class="chat-side-chat__meta">Not saved to chat history</span>
|
||||
</div>
|
||||
<div class="chat-side-chat__actions">
|
||||
<button class="btn btn--ghost btn--icon chat-icon-btn">${iconSvg()}</button>
|
||||
<button class="btn btn--ghost btn--icon chat-icon-btn">${iconSvg()}</button>
|
||||
opts.sessionRailBody !== undefined
|
||||
? `<openclaw-chat-session-rail>
|
||||
<section class="chat-session-rail chat-session-rail--expanded" role="region" aria-label="Session companion">
|
||||
<header class="chat-session-rail__header">
|
||||
<div class="chat-session-rail__header-copy">
|
||||
<strong class="chat-session-rail__headline">Reviewing the session</strong>
|
||||
</div>
|
||||
</header>
|
||||
<div class="chat-side-chat__scroll">
|
||||
<article class="chat-side-chat__turn">
|
||||
<div class="chat-side-chat__question">What should I check next?</div>
|
||||
<div class="chat-side-chat__answer">${opts.sideChatBody}</div>
|
||||
<div class="chat-session-rail__thread">
|
||||
<article class="chat-session-rail__exchange">
|
||||
<div class="chat-session-rail__question">What should I check next?</div>
|
||||
<div class="chat-session-rail__answer">${opts.sessionRailBody}</div>
|
||||
</article>
|
||||
</div>
|
||||
<footer class="chat-side-chat__composer">
|
||||
<div class="chat-side-chat__prompt">
|
||||
<input class="chat-side-chat__input" type="text" placeholder="Follow up…" />
|
||||
<button class="btn btn--ghost btn--icon chat-icon-btn chat-side-chat__send">${iconSvg()}</button>
|
||||
</div>
|
||||
<footer class="chat-session-rail__composer">
|
||||
<label class="chat-session-rail__prompt">
|
||||
<input class="chat-session-rail__input" type="text" placeholder="What should I know?" />
|
||||
</label>
|
||||
<button class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__submit">${iconSvg()}</button>
|
||||
</footer>
|
||||
</section>`
|
||||
</section>
|
||||
</openclaw-chat-session-rail>`
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-shell">
|
||||
@@ -580,7 +578,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("pins the session observer to the pane header edge", async () => {
|
||||
it("pins the collapsed session rail to the pane header edge", async () => {
|
||||
const page = await openBrowserPage(922, 282);
|
||||
try {
|
||||
const splitViewCss = readStyleSheet("ui/src/styles/chat/split-view.css");
|
||||
@@ -590,9 +588,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
<div class="chat-pane__header">Current session</div>
|
||||
<div class="chat-split-view__pane">
|
||||
<div class="chat-main" style="height: 100%;">
|
||||
<div class="chat-observer-hud chat-observer-hud--pill">
|
||||
<span class="chat-observer-hud__status" data-health="on-track">On track</span>
|
||||
<span class="chat-observer-hud__headline">Investigating repository guidance</span>
|
||||
<div class="chat-session-rail chat-session-rail--pill">
|
||||
<span class="chat-session-rail__status" data-health="on-track">On track</span>
|
||||
<span class="chat-session-rail__headline">Investigating repository guidance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,7 +599,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
);
|
||||
|
||||
const header = await getBoundingBox(page, ".chat-pane__header");
|
||||
const observer = await getBoundingBox(page, ".chat-observer-hud");
|
||||
const observer = await getBoundingBox(page, ".chat-session-rail");
|
||||
|
||||
expect(observer.y).toBeCloseTo(header.y + header.height, 0);
|
||||
} finally {
|
||||
@@ -2404,13 +2402,13 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
[1024, 768],
|
||||
[1366, 900],
|
||||
] as const)(
|
||||
"scrolls long side-chat conversations instead of expanding the panel at %sx%s",
|
||||
"scrolls long session-rail conversations instead of expanding the overlay at %sx%s",
|
||||
async (width, height) => {
|
||||
const page = await openFixture(width, height, {
|
||||
sideChatBody: LONG_SIDE_CHAT_BODY,
|
||||
sessionRailBody: LONG_SESSION_RAIL_BODY,
|
||||
});
|
||||
try {
|
||||
const panel = await page.locator(".chat-side-chat").evaluate((node) => {
|
||||
const panel = await page.locator(".chat-session-rail").evaluate((node) => {
|
||||
const element = node as HTMLElement;
|
||||
return {
|
||||
clientHeight: element.clientHeight,
|
||||
@@ -2418,9 +2416,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
});
|
||||
expect(panel.position).toBe("absolute");
|
||||
expect(panel.clientHeight).toBeLessThanOrEqual(560);
|
||||
expect(panel.clientHeight).toBeLessThanOrEqual(680);
|
||||
|
||||
const body = await page.locator(".chat-side-chat__scroll").evaluate((node) => {
|
||||
const body = await page.locator(".chat-session-rail__thread").evaluate((node) => {
|
||||
const style = getComputedStyle(node as HTMLElement);
|
||||
return {
|
||||
overflowY: style.overflowY,
|
||||
@@ -2431,7 +2429,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(body.overflowY).toBe("auto");
|
||||
expect(body.clientHeight).toBeLessThan(body.scrollHeight);
|
||||
|
||||
const scrollTop = await page.locator(".chat-side-chat__scroll").evaluate((node) => {
|
||||
const scrollTop = await page.locator(".chat-session-rail__thread").evaluate((node) => {
|
||||
const element = node as HTMLElement;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
return element.scrollTop;
|
||||
@@ -2443,13 +2441,13 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("renders the side chat as a mobile overlay without horizontal overflow", async () => {
|
||||
it("renders the session rail as a mobile overlay without horizontal overflow", async () => {
|
||||
const page = await openFixture(320, 568, {
|
||||
sideChatBody: LONG_SIDE_CHAT_BODY,
|
||||
sessionRailBody: LONG_SESSION_RAIL_BODY,
|
||||
});
|
||||
try {
|
||||
await expectNoHorizontalOverflow(page);
|
||||
const panel = await page.locator(".chat-side-chat").evaluate((node) => {
|
||||
const panel = await page.locator(".chat-session-rail").evaluate((node) => {
|
||||
const element = node as HTMLElement;
|
||||
return {
|
||||
clientHeight: element.clientHeight,
|
||||
@@ -2457,9 +2455,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
});
|
||||
expect(panel.position).toBe("fixed");
|
||||
expect(panel.clientHeight).toBeLessThanOrEqual(380);
|
||||
expect(panel.clientHeight).toBeLessThanOrEqual(460);
|
||||
|
||||
const scroll = await page.locator(".chat-side-chat__scroll").evaluate((node) => {
|
||||
const scroll = await page.locator(".chat-session-rail__thread").evaluate((node) => {
|
||||
const element = node as HTMLElement;
|
||||
return {
|
||||
overflowY: getComputedStyle(element).overflowY,
|
||||
@@ -2470,7 +2468,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(scroll.overflowY).toBe("auto");
|
||||
expect(scroll.clientHeight).toBeLessThan(scroll.scrollHeight);
|
||||
|
||||
const scrollTop = await page.locator(".chat-side-chat__scroll").evaluate((node) => {
|
||||
const scrollTop = await page.locator(".chat-session-rail__thread").evaluate((node) => {
|
||||
const element = node as HTMLElement;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
return element.scrollTop;
|
||||
@@ -2480,5 +2478,26 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("docks an expanded session rail as a static 400px column on wide chat panes", async () => {
|
||||
const page = await openFixture(1440, 900, {
|
||||
sessionRailBody: LONG_SESSION_RAIL_BODY,
|
||||
sessionRailDocked: true,
|
||||
});
|
||||
try {
|
||||
const main = page.locator(".chat-main");
|
||||
await expect(
|
||||
main.evaluate((node) => node.classList.contains("chat-main--rail-docked")),
|
||||
).resolves.toBe(true);
|
||||
const rail = await page.locator(".chat-session-rail").evaluate((node) => ({
|
||||
position: getComputedStyle(node as HTMLElement).position,
|
||||
width: (node as HTMLElement).getBoundingClientRect().width,
|
||||
}));
|
||||
expect(rail.position).toBe("static");
|
||||
expect(rail.width).toBeCloseTo(400, 0);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AgentsListResult } from "../../api/types.ts";
|
||||
import type { ChatFollowUpMode } from "../../app/settings.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
|
||||
import type { ChatSideResultPending } from "../../lib/chat/side-result.ts";
|
||||
import type { SessionCapability, SessionRefreshTarget } from "../../lib/sessions/index.ts";
|
||||
import type { ChatCommandHost } from "./chat-commands.ts";
|
||||
import type { ChatRunStartupState } from "./chat-run-startup.ts";
|
||||
@@ -54,10 +53,6 @@ export type ChatHost = ChatInputHistoryState &
|
||||
senderLabel?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
} | null;
|
||||
/** Placeholder for an in-flight /btw side question awaiting chat.side_result. */
|
||||
chatSideResultPending?: ChatSideResultPending | null;
|
||||
/** Retired/handled BTW run ids whose late events must not reach the transcript. */
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
/** Side-chat panel closed via X/Escape; a new question reopens it. */
|
||||
chatSideChatHidden?: boolean;
|
||||
/** Control UI route for /btw and /side; server/TUI command handling remains unchanged. */
|
||||
openSessionCompanion?: (question: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -305,7 +305,6 @@ export async function sendQueuedChatMessage(
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
clearToolStream: true,
|
||||
clearSideResultTerminalRuns: true,
|
||||
publishRunStatus: false,
|
||||
armLocalTerminalReconcile: ack.runId === runId,
|
||||
},
|
||||
@@ -351,7 +350,6 @@ export async function sendQueuedChatMessage(
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
clearToolStream: true,
|
||||
clearSideResultTerminalRuns: true,
|
||||
publishRunStatus: false,
|
||||
armLocalTerminalReconcile: true,
|
||||
},
|
||||
@@ -384,7 +382,6 @@ export async function sendQueuedChatMessage(
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
clearToolStream: true,
|
||||
clearSideResultTerminalRuns: true,
|
||||
publishRunStatus: false,
|
||||
armLocalTerminalReconcile: ack.runId === runId,
|
||||
},
|
||||
|
||||
@@ -6,12 +6,10 @@ import type {
|
||||
ChatQueueSkillWorkshopRevision,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import { parseSlashCommand } from "../../lib/chat/commands.ts";
|
||||
import { extractCompanionCommandQuestion } from "../../lib/chat/companion-question.ts";
|
||||
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
|
||||
import { extractSideQuestionDisplayText } from "../../lib/chat/side-question.ts";
|
||||
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
|
||||
import { visibleSessionMatches } from "../../lib/sessions/index.ts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
|
||||
import { generateUUID } from "../../lib/uuid.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
releaseChatAttachmentPayloads,
|
||||
@@ -69,12 +67,6 @@ type ChatSendOptions = {
|
||||
confirmReset?: boolean;
|
||||
restoreDraft?: boolean;
|
||||
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision;
|
||||
/** Side-chat follow-ups embed prior-turn context in the /btw command; the
|
||||
* pending turn must display the user's typed question instead. */
|
||||
sideQuestionDisplayText?: string;
|
||||
/** Lets the side-chat panel restore its typed follow-up when the detached
|
||||
* send is not accepted (the panel input is not a managed draft). */
|
||||
onSideQuestionSendRejected?: () => void;
|
||||
/** Lets request-scoped UI actions recover when their local slash command
|
||||
* fails before the Gateway accepts it. */
|
||||
onLocalCommandSendRejected?: () => void;
|
||||
@@ -254,16 +246,29 @@ export async function handleSendChat(
|
||||
}
|
||||
|
||||
const parsed = parseSlashCommand(message);
|
||||
if (isBtwCommand(message)) {
|
||||
const question = extractCompanionCommandQuestion(message);
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
const submitKey = chatSubmitKey(host, "local", message, []);
|
||||
await withChatSubmitGuard(host, submitKey, async () => {
|
||||
if (messageOverride == null) {
|
||||
recordNonTranscriptInputHistory(host, message);
|
||||
if (host.chatMessage === previousDraft) {
|
||||
host.chatMessage = "";
|
||||
resetChatInputHistoryNavigation(host);
|
||||
}
|
||||
}
|
||||
await host.openSessionCompanion?.(question);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The backend resolves /approve before active-run admission. Send it now so
|
||||
// the approval command cannot queue behind the run that is waiting for it.
|
||||
const shouldSendDetachedCommand =
|
||||
isBtwCommand(message) || (parsed?.command.key === "approve" && isChatBusy(host));
|
||||
const shouldSendDetachedCommand = parsed?.command.key === "approve" && isChatBusy(host);
|
||||
if (shouldSendDetachedCommand) {
|
||||
const submitKey = chatSubmitKey(host, "detached", message, attachmentsToSend);
|
||||
// Covers every non-accepted path — early exits, guard dedupe, and
|
||||
// rejected acks — so the side-chat panel can restore its typed
|
||||
// follow-up even when no request was sent.
|
||||
let detachedSendAccepted = false;
|
||||
await withChatSubmitGuard(host, submitKey, async () => {
|
||||
const pendingSettings = getPendingChatPickerPatch(host, submittedSessionKey);
|
||||
if (
|
||||
@@ -282,44 +287,13 @@ export async function handleSendChat(
|
||||
if (messageOverride == null) {
|
||||
recordNonTranscriptInputHistory(host, message);
|
||||
}
|
||||
// BTW runs detached and delivers via chat.side_result only; show a
|
||||
// pending turn immediately so the send has visible feedback. The run
|
||||
// id is generated upfront so the turn is correlatable before the ack
|
||||
// returns.
|
||||
const btwPending = isBtwCommand(message)
|
||||
? {
|
||||
question: opts?.sideQuestionDisplayText ?? extractSideQuestionDisplayText(message),
|
||||
ts: Date.now(),
|
||||
runId: generateUUID(),
|
||||
}
|
||||
: null;
|
||||
if (btwPending) {
|
||||
// The superseded run loses its pending record; retire it so its
|
||||
// late side_result/terminal events cannot reach the panel or the
|
||||
// transcript. Completed turns stay: the panel is a conversation.
|
||||
retirePendingChatSideQuestion(host);
|
||||
host.chatSideResultPending = btwPending;
|
||||
host.chatSideChatHidden = false;
|
||||
host.requestUpdate?.();
|
||||
}
|
||||
const ack = await sendDetachedCommandMessage(host, message, {
|
||||
previousDraft: cleared.previousDraft,
|
||||
attachments: hasAttachments ? attachmentsToSend : undefined,
|
||||
previousAttachments: cleared.previousAttachments,
|
||||
runId: btwPending?.runId,
|
||||
});
|
||||
detachedSendAccepted =
|
||||
ack?.status === "ok" || ack?.status === "started" || ack?.status === "in_flight";
|
||||
// Touch only this send's card: a side_result (or a newer question)
|
||||
// may already have replaced it while the ack was in flight.
|
||||
if (btwPending && host.chatSideResultPending === btwPending && !detachedSendAccepted) {
|
||||
host.chatSideResultPending = null;
|
||||
host.requestUpdate?.();
|
||||
}
|
||||
void ack;
|
||||
});
|
||||
if (!detachedSendAccepted) {
|
||||
opts?.onSideQuestionSendRejected?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -363,8 +363,6 @@ function makeHost(overrides?: MakeHostOverrides): TestChatHost | TestChatHostWit
|
||||
chatAvatarSource: null,
|
||||
chatAvatarStatus: null,
|
||||
chatAvatarReason: null,
|
||||
chatSideChatTurns: [],
|
||||
chatSideResultTerminalRuns: new Set<string>(),
|
||||
sessionsLoading: false,
|
||||
sessionsResult: null,
|
||||
sessionsResultAgentId: null,
|
||||
@@ -2919,29 +2917,18 @@ describe("handleSendChat", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends /btw immediately while a main run is active without queueing it", async () => {
|
||||
it("routes /btw to the session companion while a main run is active", async () => {
|
||||
const openSessionCompanion = vi.fn();
|
||||
const host = makeHost({
|
||||
requestHandlers: {
|
||||
"chat.send": {},
|
||||
},
|
||||
chatRunId: "run-main",
|
||||
chatStream: "Working...",
|
||||
chatMessage: "/btw what changed?",
|
||||
openSessionCompanion,
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
const payload = findRequestPayload(
|
||||
host.request as unknown as MockCallSource,
|
||||
"chat.send",
|
||||
"chat send payload",
|
||||
);
|
||||
expect(payload.sessionKey).toBe("agent:main");
|
||||
expect(payload.message).toBe("/btw what changed?");
|
||||
expect(payload.deliver).toBe(false);
|
||||
const idempotencyKey = payload.idempotencyKey;
|
||||
expect(typeof idempotencyKey).toBe("string");
|
||||
expect(uuidPattern.test(idempotencyKey as string)).toBe(true);
|
||||
expect(openSessionCompanion).toHaveBeenCalledWith("what changed?");
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBe("run-main");
|
||||
expect(host.chatStream).toBe("Working...");
|
||||
@@ -2981,46 +2968,32 @@ describe("handleSendChat", () => {
|
||||
expect(host.chatMessage).toBe("/approve approval-123 allow-once");
|
||||
});
|
||||
|
||||
it("sends /side through the detached BTW path", async () => {
|
||||
it("routes /side through the same session companion path", async () => {
|
||||
const openSessionCompanion = vi.fn();
|
||||
const host = makeHost({
|
||||
requestHandlers: {
|
||||
"chat.send": {},
|
||||
},
|
||||
chatRunId: "run-main",
|
||||
chatStream: "Working...",
|
||||
chatMessage: "/side what changed?",
|
||||
openSessionCompanion,
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
const payload = findRequestPayload(
|
||||
host.request as unknown as MockCallSource,
|
||||
"chat.send",
|
||||
"chat send payload",
|
||||
);
|
||||
expect(payload.message).toBe("/side what changed?");
|
||||
expect(payload.deliver).toBe(false);
|
||||
expect(openSessionCompanion).toHaveBeenCalledWith("what changed?");
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBe("run-main");
|
||||
});
|
||||
|
||||
it("sends /btw without adopting a main chat run when idle", async () => {
|
||||
it("routes /btw without adopting a main chat run when idle", async () => {
|
||||
const openSessionCompanion = vi.fn();
|
||||
const host = makeHost({
|
||||
requestHandlers: {
|
||||
"chat.send": {},
|
||||
},
|
||||
chatMessage: "/btw summarize this",
|
||||
openSessionCompanion,
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
const payload = findRequestPayload(
|
||||
host.request as unknown as MockCallSource,
|
||||
"chat.send",
|
||||
"chat send payload",
|
||||
);
|
||||
expect(payload.message).toBe("/btw summarize this");
|
||||
expect(payload.deliver).toBe(false);
|
||||
expect(openSessionCompanion).toHaveBeenCalledWith("summarize this");
|
||||
expect(host.chatRunId).toBeNull();
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatMessage).toBe("");
|
||||
@@ -6459,77 +6432,7 @@ describe("handleSendChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "restores the BTW draft when detached send fails",
|
||||
response: () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
error: "network down",
|
||||
},
|
||||
{
|
||||
name: "restores the BTW draft when detached send returns a terminal timeout ACK",
|
||||
response: { runId: "btw-terminal", status: "timeout" },
|
||||
error: "The active run ended before the detached message was accepted.",
|
||||
},
|
||||
])("$name", async ({ response, error }) => {
|
||||
const host = makeHost({
|
||||
requestHandlers: { "chat.send": response },
|
||||
chatRunId: "run-main",
|
||||
chatStream: "Working...",
|
||||
chatMessage: "/btw what changed?",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBe("run-main");
|
||||
expect(host.chatStream).toBe("Working...");
|
||||
expect(host.chatMessage).toBe("/btw what changed?");
|
||||
expect(host.lastError).toBe(error);
|
||||
});
|
||||
|
||||
it("notifies side-chat rejection on failed sends and pre-send exits", async () => {
|
||||
const onSideQuestionSendRejected = vi.fn();
|
||||
const host = makeHost({
|
||||
client: clientWithRequest(
|
||||
makeRequestMock({
|
||||
"chat.send": { runId: "btw-rejected", status: "timeout" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await handleSendChat(host, "/btw and why?", {
|
||||
sideQuestionDisplayText: "and why?",
|
||||
onSideQuestionSendRejected,
|
||||
});
|
||||
expect(onSideQuestionSendRejected).toHaveBeenCalledTimes(1);
|
||||
expect(host.chatSideResultPending).toBeNull();
|
||||
|
||||
// Pre-send exit (session switched away before the guarded send ran) must
|
||||
// also notify: the panel cleared its input when it handed the command off.
|
||||
const switchingHost = makeHost({
|
||||
client: clientWithRequest(
|
||||
vi.fn(async () => {
|
||||
throw new Error("must not send");
|
||||
}),
|
||||
),
|
||||
chatSubmitGuards: new Map(),
|
||||
});
|
||||
const originalGuards = switchingHost.chatSubmitGuards;
|
||||
// Simulate the session switching between submit and the guarded body.
|
||||
Object.defineProperty(switchingHost, "sessionKey", {
|
||||
configurable: true,
|
||||
get: () => (originalGuards?.size ? "other-session" : "main"),
|
||||
});
|
||||
await handleSendChat(switchingHost, "/btw and why?", {
|
||||
sideQuestionDisplayText: "and why?",
|
||||
onSideQuestionSendRejected,
|
||||
});
|
||||
expect(onSideQuestionSendRejected).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears BTW side results when /clear resets chat history", async () => {
|
||||
it("clears chat state when /clear resets chat history", async () => {
|
||||
const host = makeHost({
|
||||
requestHandlers: {
|
||||
"sessions.reset": { ok: true },
|
||||
@@ -6539,18 +6442,6 @@ describe("handleSendChat", () => {
|
||||
chatMessage: "/clear",
|
||||
chatMessages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
chatRunError: { summary: "Error: previous run failed" },
|
||||
chatSideChatTurns: [
|
||||
{
|
||||
kind: "btw",
|
||||
runId: "btw-run-clear",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "Detached BTW result",
|
||||
isError: false,
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
chatSideResultTerminalRuns: new Set(["btw-run-clear"]),
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
@@ -6558,8 +6449,6 @@ describe("handleSendChat", () => {
|
||||
expect(host.request).toHaveBeenCalledWith("sessions.reset", { key: "main" });
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatRunError).toBeNull();
|
||||
expect(host.chatSideChatTurns).toEqual([]);
|
||||
expect(host.chatSideResultTerminalRuns?.size).toBe(0);
|
||||
expect(host.chatRunId).toBeNull();
|
||||
expect(host.chatStream).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
SessionCompanionExchange,
|
||||
SessionsCompanionAskResult,
|
||||
SessionsCompanionResetResult,
|
||||
SessionsCompanionStateResult,
|
||||
} from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
const COMPANION_BUSY_DETAIL_CODE = "SESSION_COMPANION_BUSY";
|
||||
const MAX_COMPANION_EXCHANGES = 24;
|
||||
|
||||
export type ChatSessionCompanionThread = {
|
||||
exchanges: SessionCompanionExchange[];
|
||||
pendingQuestion: string | null;
|
||||
failedQuestion: string | null;
|
||||
hint: "busy" | "unavailable" | null;
|
||||
draft: string;
|
||||
};
|
||||
|
||||
type MutableCompanionThread = ChatSessionCompanionThread & {
|
||||
revision: number;
|
||||
};
|
||||
|
||||
function errorDetailCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") {
|
||||
return null;
|
||||
}
|
||||
const details = (error as { details?: unknown }).details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = (details as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : null;
|
||||
}
|
||||
|
||||
function createThread(): MutableCompanionThread {
|
||||
return {
|
||||
exchanges: [],
|
||||
pendingQuestion: null,
|
||||
failedQuestion: null,
|
||||
hint: null,
|
||||
draft: "",
|
||||
revision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pane-owned ephemeral companion threads, keyed by the exact selected session. */
|
||||
export class ChatSessionCompanionThreads {
|
||||
private readonly threads = new Map<string, MutableCompanionThread>();
|
||||
private readonly hydrationTokens = new Map<string, symbol>();
|
||||
|
||||
constructor(private readonly notify: () => void = () => {}) {}
|
||||
|
||||
view(sessionKey: string): ChatSessionCompanionThread {
|
||||
return this.get(sessionKey);
|
||||
}
|
||||
|
||||
setDraft(sessionKey: string, draft: string): void {
|
||||
const thread = this.get(sessionKey);
|
||||
if (thread.draft === draft) {
|
||||
return;
|
||||
}
|
||||
thread.draft = draft;
|
||||
thread.revision += 1;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async hydrate(
|
||||
sessionKey: string,
|
||||
load: (sessionKey: string) => Promise<SessionsCompanionStateResult>,
|
||||
): Promise<void> {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const thread = this.get(key);
|
||||
const revision = thread.revision;
|
||||
const token = Symbol(key);
|
||||
this.hydrationTokens.set(key, token);
|
||||
try {
|
||||
const result = await load(key);
|
||||
if (this.hydrationTokens.get(key) !== token || thread.revision !== revision) {
|
||||
return;
|
||||
}
|
||||
thread.exchanges = result.exchanges.map(({ question, answer, ts }) => ({
|
||||
question,
|
||||
answer,
|
||||
ts,
|
||||
}));
|
||||
thread.failedQuestion = null;
|
||||
thread.hint = null;
|
||||
thread.revision += 1;
|
||||
this.notify();
|
||||
} catch {
|
||||
// A disconnected or older Gateway should not erase a thread already
|
||||
// visible in this pane. Ask failures surface an actionable inline hint.
|
||||
} finally {
|
||||
if (this.hydrationTokens.get(key) === token) {
|
||||
this.hydrationTokens.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async submit(
|
||||
sessionKey: string,
|
||||
question: string,
|
||||
ask: (sessionKey: string, question: string) => Promise<SessionsCompanionAskResult>,
|
||||
): Promise<void> {
|
||||
const key = sessionKey.trim();
|
||||
const normalized = question.trim();
|
||||
if (!key || !normalized) {
|
||||
return;
|
||||
}
|
||||
const thread = this.get(key);
|
||||
if (thread.pendingQuestion) {
|
||||
return;
|
||||
}
|
||||
thread.pendingQuestion = normalized;
|
||||
thread.failedQuestion = null;
|
||||
thread.hint = null;
|
||||
thread.draft = "";
|
||||
thread.revision += 1;
|
||||
this.notify();
|
||||
try {
|
||||
const result = await ask(key, normalized);
|
||||
thread.exchanges = [
|
||||
...thread.exchanges,
|
||||
{ question: normalized, answer: result.answer, ts: result.ts },
|
||||
].slice(-MAX_COMPANION_EXCHANGES);
|
||||
} catch (error) {
|
||||
thread.failedQuestion = normalized;
|
||||
thread.hint = errorDetailCode(error) === COMPANION_BUSY_DETAIL_CODE ? "busy" : "unavailable";
|
||||
} finally {
|
||||
thread.pendingQuestion = null;
|
||||
thread.revision += 1;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async reset(
|
||||
sessionKey: string,
|
||||
clear: (sessionKey: string) => Promise<SessionsCompanionResetResult>,
|
||||
): Promise<void> {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
await clear(key);
|
||||
this.hydrationTokens.delete(key);
|
||||
this.threads.set(key, createThread());
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private get(sessionKey: string): MutableCompanionThread {
|
||||
const key = sessionKey.trim();
|
||||
let thread = this.threads.get(key);
|
||||
if (!thread) {
|
||||
thread = createThread();
|
||||
this.threads.set(key, thread);
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestSessionCompanionAnswer(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
sessionKey: string,
|
||||
question: string,
|
||||
): Promise<SessionsCompanionAskResult> {
|
||||
return client.request<SessionsCompanionAskResult>("sessions.companion.ask", {
|
||||
sessionKey,
|
||||
question,
|
||||
});
|
||||
}
|
||||
|
||||
export function requestSessionCompanionState(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
sessionKey: string,
|
||||
): Promise<SessionsCompanionStateResult> {
|
||||
return client.request<SessionsCompanionStateResult>("sessions.companion.state", { sessionKey });
|
||||
}
|
||||
|
||||
export function resetSessionCompanion(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
sessionKey: string,
|
||||
): Promise<SessionsCompanionResetResult> {
|
||||
return client.request<SessionsCompanionResetResult>("sessions.companion.reset", { sessionKey });
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import {
|
||||
ChatSessionCompanionThreads,
|
||||
requestSessionCompanionAnswer,
|
||||
requestSessionCompanionState,
|
||||
resetSessionCompanion,
|
||||
} from "./chat-session-companion.ts";
|
||||
import {
|
||||
ChatSessionRailElement,
|
||||
ChatSessionRailState,
|
||||
type SessionRailInput,
|
||||
} from "./components/chat-session-rail.ts";
|
||||
|
||||
function digest(health: SessionObserverDigest["health"] = "on-track"): SessionObserverDigest {
|
||||
return {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
revision: 1,
|
||||
updatedAt: 300_000,
|
||||
headline: "Reviewing the implementation",
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
function input(overrides: Partial<SessionRailInput> = {}): SessionRailInput {
|
||||
return {
|
||||
running: true,
|
||||
activeRunId: "run-1",
|
||||
digest: digest(),
|
||||
hasCompanionActivity: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ChatSessionRailState", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("moves between restore-icon, pill, and expanded modes", () => {
|
||||
const state = new ChatSessionRailState("pill");
|
||||
// Idle with nothing to show keeps the restore icon: the companion must
|
||||
// stay one click away at any point, never fully hidden.
|
||||
expect(state.mode(input({ running: false, digest: null }))).toBe("restore-icon");
|
||||
expect(state.mode(input())).toBe("pill");
|
||||
state.expand();
|
||||
expect(state.mode(input())).toBe("expanded");
|
||||
state.collapse();
|
||||
expect(state.mode(input())).toBe("pill");
|
||||
state.hide();
|
||||
expect(state.mode(input())).toBe("restore-icon");
|
||||
});
|
||||
|
||||
it("opens digest-less from the restore icon and resets per session", () => {
|
||||
const state = new ChatSessionRailState("pill");
|
||||
const idle = { running: false, activeRunId: null, digest: null } as const;
|
||||
state.show();
|
||||
expect(state.mode(input(idle))).toBe("pill");
|
||||
state.resetManualOpen();
|
||||
expect(state.mode(input(idle))).toBe("restore-icon");
|
||||
});
|
||||
|
||||
it("keeps a companion thread renderable without an observer digest", () => {
|
||||
const state = new ChatSessionRailState("pill");
|
||||
expect(
|
||||
state.mode(
|
||||
input({ running: false, activeRunId: null, digest: null, hasCompanionActivity: true }),
|
||||
),
|
||||
).toBe("pill");
|
||||
});
|
||||
|
||||
it("auto-expands a critical run only once", () => {
|
||||
const state = new ChatSessionRailState("pill");
|
||||
expect(state.mode(input({ digest: digest("stuck") }))).toBe("expanded");
|
||||
state.collapse();
|
||||
expect(state.mode(input({ digest: digest("waiting-on-user") }))).toBe("pill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSessionCompanionThreads", () => {
|
||||
it("uses the exact companion RPC methods and payloads", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.companion.ask") {
|
||||
return { answer: "Answer", ts: 1 };
|
||||
}
|
||||
if (method === "sessions.companion.state") {
|
||||
return { exchanges: [] };
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
const client = { request: request as GatewayBrowserClient["request"] };
|
||||
|
||||
await requestSessionCompanionAnswer(client, "one", "Question");
|
||||
await requestSessionCompanionState(client, "one");
|
||||
await resetSessionCompanion(client, "one");
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["sessions.companion.ask", { sessionKey: "one", question: "Question" }],
|
||||
["sessions.companion.state", { sessionKey: "one" }],
|
||||
["sessions.companion.reset", { sessionKey: "one" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates and retains independent per-session threads", async () => {
|
||||
const threads = new ChatSessionCompanionThreads();
|
||||
const load = vi.fn(async (sessionKey: string) => ({
|
||||
exchanges: [
|
||||
{
|
||||
question: `Question for ${sessionKey}`,
|
||||
answer: `Answer for ${sessionKey}`,
|
||||
ts: sessionKey === "one" ? 1 : 2,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
await threads.hydrate("one", load);
|
||||
await threads.hydrate("two", load);
|
||||
|
||||
expect(threads.view("one").exchanges[0]?.answer).toBe("Answer for one");
|
||||
expect(threads.view("two").exchanges[0]?.answer).toBe("Answer for two");
|
||||
});
|
||||
|
||||
it("moves a composer submission through pending to a timestamped answer", async () => {
|
||||
let resolveAnswer!: (value: { answer: string; ts: number }) => void;
|
||||
const threads = new ChatSessionCompanionThreads();
|
||||
threads.setDraft("one", "Why is it rerunning that test?");
|
||||
const pending = threads.submit(
|
||||
"one",
|
||||
threads.view("one").draft,
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveAnswer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(threads.view("one").pendingQuestion).toBe("Why is it rerunning that test?");
|
||||
expect(threads.view("one").draft).toBe("");
|
||||
resolveAnswer({ answer: "It is verifying the focused regression.", ts: 42 });
|
||||
await pending;
|
||||
|
||||
expect(threads.view("one")).toMatchObject({
|
||||
pendingQuestion: null,
|
||||
exchanges: [
|
||||
{
|
||||
question: "Why is it rerunning that test?",
|
||||
answer: "It is verifying the focused regression.",
|
||||
ts: 42,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the typed busy error to the rail hint", async () => {
|
||||
const threads = new ChatSessionCompanionThreads();
|
||||
await threads.submit("one", "Is it stuck?", async () => {
|
||||
throw Object.assign(new Error("busy"), {
|
||||
details: { code: "SESSION_COMPANION_BUSY" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(threads.view("one")).toMatchObject({
|
||||
failedQuestion: "Is it stuck?",
|
||||
hint: "busy",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears local state only after the reset RPC succeeds", async () => {
|
||||
const threads = new ChatSessionCompanionThreads();
|
||||
await threads.hydrate("one", async () => ({
|
||||
exchanges: [{ question: "Q", answer: "A", ts: 1 }],
|
||||
}));
|
||||
await expect(
|
||||
threads.reset("one", async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
).rejects.toThrow("offline");
|
||||
expect(threads.view("one").exchanges).toHaveLength(1);
|
||||
|
||||
await threads.reset("one", async () => ({ ok: true as const }));
|
||||
expect(threads.view("one").exchanges).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSessionRailElement", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
localStorage.setItem("openclaw.chat.observerHud.display", "card");
|
||||
vi.spyOn(Date, "now").mockReturnValue(600_000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function mount(overrides: Partial<ChatSessionRailElement> = {}) {
|
||||
const element = new ChatSessionRailElement();
|
||||
element.sessionKey = "agent:main:run";
|
||||
element.digest = digest();
|
||||
element.running = true;
|
||||
element.activeRunId = "run-1";
|
||||
element.startedAt = 500_000;
|
||||
element.connected = true;
|
||||
Object.assign(element, overrides);
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
}
|
||||
|
||||
it("submits the rail composer and renders sanitized markdown answers", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const element = await mount({
|
||||
onSubmit,
|
||||
companion: {
|
||||
exchanges: [
|
||||
{
|
||||
question: "What changed?",
|
||||
answer: "**Only** the UI. <script>bad()</script>",
|
||||
ts: 300_000,
|
||||
},
|
||||
],
|
||||
pendingQuestion: null,
|
||||
failedQuestion: null,
|
||||
hint: null,
|
||||
draft: "What should I verify?",
|
||||
},
|
||||
});
|
||||
|
||||
element.querySelector("form")?.dispatchEvent(new SubmitEvent("submit", { bubbles: true }));
|
||||
expect(onSubmit).toHaveBeenCalledWith("What should I verify?");
|
||||
expect(element.querySelector(".chat-session-rail__answer strong")?.textContent).toBe("Only");
|
||||
expect(element.querySelector("script")).toBeNull();
|
||||
expect(element.querySelector(".chat-session-rail__timestamp")?.textContent).toContain("as of");
|
||||
});
|
||||
|
||||
it("freezes terminal relative time from digest.updatedAt", async () => {
|
||||
const element = await mount({
|
||||
digest: digest("done"),
|
||||
running: false,
|
||||
activeRunId: null,
|
||||
companion: {
|
||||
exchanges: [{ question: "Q", answer: "A", ts: 1 }],
|
||||
pendingQuestion: null,
|
||||
failedQuestion: null,
|
||||
hint: null,
|
||||
draft: "",
|
||||
},
|
||||
});
|
||||
expect(element.textContent).toContain("Finished 5m ago");
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(3_600_000);
|
||||
element.requestUpdate();
|
||||
await element.updateComplete;
|
||||
expect(element.textContent).toContain("Finished 5m ago");
|
||||
});
|
||||
|
||||
it("uses an uppercase chip only for stuck and waiting states", async () => {
|
||||
const element = await mount({ digest: digest("stuck") });
|
||||
expect(element.querySelector(".chat-session-rail__status--critical")).not.toBeNull();
|
||||
|
||||
element.digest = digest("on-track");
|
||||
await element.updateComplete;
|
||||
expect(element.querySelector(".chat-session-rail__status--critical")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses on Escape", async () => {
|
||||
const element = await mount();
|
||||
element
|
||||
.querySelector(".chat-session-rail--expanded")
|
||||
?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await element.updateComplete;
|
||||
expect(element.querySelector(".chat-session-rail--pill")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ChatSideResult } from "../../lib/chat/side-result.ts";
|
||||
import { renderSideChatPanel } from "./components/chat-side-chat.ts";
|
||||
|
||||
function turn(overrides: Partial<ChatSideResult> = {}): ChatSideResult {
|
||||
return {
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "The web UI now renders side chats separately.",
|
||||
isError: false,
|
||||
ts: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("side chat panel render", () => {
|
||||
it("renders turns, header actions, and the follow-up composer", () => {
|
||||
const container = document.createElement("div");
|
||||
const onClose = vi.fn();
|
||||
const onClear = vi.fn();
|
||||
|
||||
render(
|
||||
renderSideChatPanel({
|
||||
turns: [turn(), turn({ runId: "btw-run-2", question: "and why?", text: "Because." })],
|
||||
pending: null,
|
||||
hidden: false,
|
||||
canFollowUp: true,
|
||||
onFollowUp: vi.fn(),
|
||||
onClose,
|
||||
onClear,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const panel = container.querySelector<HTMLElement>(".chat-side-chat");
|
||||
expect(panel).toBeInstanceOf(HTMLElement);
|
||||
expect(panel!.getAttribute("aria-label")).toBe("Side chat");
|
||||
expect(panel!.querySelector(".chat-side-chat__title")?.textContent).toBe("Side chat");
|
||||
expect(panel!.querySelector(".chat-side-chat__meta")?.textContent).toBe(
|
||||
"Not saved to chat history",
|
||||
);
|
||||
const turns = panel!.querySelectorAll(".chat-side-chat__turn");
|
||||
expect(turns).toHaveLength(2);
|
||||
expect(turns[1]?.querySelector(".chat-side-chat__question")?.textContent).toBe("and why?");
|
||||
expect(turns[1]?.querySelector(".chat-side-chat__answer")?.textContent?.trim()).toBe(
|
||||
"Because.",
|
||||
);
|
||||
expect(panel!.querySelector(".chat-side-chat__input")).toBeInstanceOf(HTMLInputElement);
|
||||
|
||||
panel!.querySelector<HTMLButtonElement>('[aria-label="Close side chat"]')?.click();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
panel!.querySelector<HTMLButtonElement>('[aria-label="Clear side chat"]')?.click();
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks error turns and shows a thinking row while a question is pending", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderSideChatPanel({
|
||||
turns: [turn({ isError: true, text: "The side question failed." })],
|
||||
pending: { question: "what failed?", ts: 3 },
|
||||
hidden: false,
|
||||
canFollowUp: false,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-side-chat__turn--error")).not.toBeNull();
|
||||
const pending = container.querySelector(".chat-side-chat__turn--pending");
|
||||
expect(pending?.querySelector(".chat-side-chat__question")?.textContent).toBe("what failed?");
|
||||
expect(pending?.querySelector(".chat-side-chat__thinking")?.textContent).toBe("Thinking…");
|
||||
// Archived sessions render the transcript without the follow-up composer.
|
||||
expect(container.querySelector(".chat-side-chat__composer")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the follow-up input while a question is pending", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderSideChatPanel({
|
||||
turns: [turn()],
|
||||
pending: { question: "and why?", ts: 3 },
|
||||
hidden: false,
|
||||
canFollowUp: true,
|
||||
onFollowUp: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(".chat-side-chat__input");
|
||||
// A new /btw while one is pending would retire the in-flight run and
|
||||
// silently drop its answer.
|
||||
expect(input?.disabled).toBe(true);
|
||||
expect(input?.placeholder).toBe("Thinking…");
|
||||
expect(container.querySelector<HTMLButtonElement>(".chat-side-chat__send")?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing while hidden or empty", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderSideChatPanel({
|
||||
turns: [turn()],
|
||||
pending: null,
|
||||
hidden: true,
|
||||
canFollowUp: true,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector(".chat-side-chat")).toBeNull();
|
||||
|
||||
render(
|
||||
renderSideChatPanel({ turns: [], pending: null, hidden: false, canFollowUp: true }),
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector(".chat-side-chat")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends follow-ups carrying the last non-error turn as context", () => {
|
||||
const container = document.createElement("div");
|
||||
// Restore-on-rejection only touches inputs still attached to the document.
|
||||
document.body.append(container);
|
||||
const onFollowUp = vi.fn();
|
||||
|
||||
render(
|
||||
renderSideChatPanel({
|
||||
turns: [
|
||||
turn({ text: "First answer." }),
|
||||
turn({ runId: "btw-run-2", isError: true, text: "It broke." }),
|
||||
],
|
||||
pending: null,
|
||||
hidden: false,
|
||||
canFollowUp: true,
|
||||
onFollowUp,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(".chat-side-chat__input");
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
input!.value = "tell me more";
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
|
||||
expect(onFollowUp).toHaveBeenCalledWith(
|
||||
'/btw Context — the previous side question "what changed?" was answered: "First answer.". Follow-up question: tell me more',
|
||||
"tell me more",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(input!.value).toBe("");
|
||||
|
||||
// A rejected detached send restores the typed follow-up.
|
||||
const onSendRejected = onFollowUp.mock.calls[0]?.[2] as () => void;
|
||||
onSendRejected();
|
||||
expect(input!.value).toBe("tell me more");
|
||||
|
||||
// ...unless the user already typed something new.
|
||||
input!.value = "different draft";
|
||||
onSendRejected();
|
||||
expect(input!.value).toBe("different draft");
|
||||
|
||||
input!.value = "";
|
||||
// Empty input must not send.
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(onFollowUp).toHaveBeenCalledTimes(1);
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -450,7 +450,6 @@ describe("session pull request refresh", () => {
|
||||
chatQueue: [],
|
||||
chatQueueByScope: {},
|
||||
chatRunId: null,
|
||||
chatSideResultTerminalRuns: new Set(),
|
||||
chatStream: null,
|
||||
chatStreamRenderFrame: null,
|
||||
chatStreamSegments: [],
|
||||
@@ -669,7 +668,6 @@ describe("route composer fallback", () => {
|
||||
dataUrl: "data:image/png;base64,AAA",
|
||||
},
|
||||
],
|
||||
chatSideResultTerminalRuns: new Set(),
|
||||
chatToolMessages: [],
|
||||
chatStreamSegments: [],
|
||||
toolStreamById: new Map(),
|
||||
|
||||
@@ -25,7 +25,6 @@ import type { ImageLightboxItem } from "../../components/image-lightbox.ts";
|
||||
import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import { extractText } from "../../lib/chat/message-extract.ts";
|
||||
import { retirePendingChatSideQuestion, type ChatSideResult } from "../../lib/chat/side-result.ts";
|
||||
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { loadModelAuthStatus } from "../../lib/model-auth.ts";
|
||||
@@ -50,11 +49,7 @@ import {
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { refreshChatAvatar, resolveAgentIdForSession } from "./chat-avatar.ts";
|
||||
import { applyRemoteSlashCommandsResult, refreshSlashCommands } from "./chat-commands.ts";
|
||||
import {
|
||||
handleChatGatewayEvent,
|
||||
handleChatSideResultGatewayEvent,
|
||||
type ChatEventPayload,
|
||||
} from "./chat-gateway.ts";
|
||||
import { handleChatGatewayEvent, type ChatEventPayload } from "./chat-gateway.ts";
|
||||
import {
|
||||
chatScopedEventSessionMatches,
|
||||
isHiddenAssistantStreamText,
|
||||
@@ -228,7 +223,6 @@ export type ChatPageHost = ChatHost &
|
||||
chatAvatarSource: string | null;
|
||||
chatAvatarStatus: "none" | "local" | "remote" | "data" | null;
|
||||
chatAvatarReason: string | null;
|
||||
chatSideResultTerminalRuns: Set<string>;
|
||||
chatModelSwitchPromises: Record<string, Promise<boolean>>;
|
||||
chatModelCatalog: ModelCatalogEntry[];
|
||||
modelAuthStatusResult: ModelAuthStatusResult | null;
|
||||
@@ -565,8 +559,6 @@ export function resetChatStateForRouteSession(
|
||||
state.chatRunUsageById = new Map();
|
||||
state.chatSending = false;
|
||||
state.chatSendingScopeKey = null;
|
||||
state.chatSideChatTurns = [];
|
||||
state.chatSideChatHidden = false;
|
||||
state.lastError = null;
|
||||
state.chatError = null;
|
||||
state.chatRunError = null;
|
||||
@@ -600,16 +592,11 @@ export function resetChatStateForRouteSession(
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
clearToolStream: true,
|
||||
clearSideResultTerminalRuns: true,
|
||||
clearRunStatus: true,
|
||||
// chat-pane adopts the new composer owner before it renders. Rendering
|
||||
// here would persist the hydrated target through the previous owner.
|
||||
requestUpdate: false,
|
||||
});
|
||||
// After the suppression-set wipe above: retire (not just drop) a pending
|
||||
// BTW run so its late resultless terminal event cannot be adopted into the
|
||||
// old session's cached transcript.
|
||||
retirePendingChatSideQuestion(state);
|
||||
state.resetChatScroll();
|
||||
// Deliberately no saveRouteSessionSettings here: this runs for every split
|
||||
// pane, and only the active pane may write the global sessionKey /
|
||||
@@ -1305,10 +1292,6 @@ export function createPageState(
|
||||
chatRunError: null,
|
||||
agentsError: null,
|
||||
chatStreamSegments: [] as Array<{ text: string; ts: number }>,
|
||||
chatSideChatTurns: [] as ChatSideResult[],
|
||||
chatSideResultPending: null,
|
||||
chatSideResultTerminalRuns: new Set<string>(),
|
||||
chatSideChatHidden: false,
|
||||
chatRunStatus: null,
|
||||
compactionStatus: null,
|
||||
fallbackStatus: null,
|
||||
@@ -1613,12 +1596,6 @@ export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventF
|
||||
requestChatPageUpdate(state, payload?.state === "delta" ? "animation-frame" : "immediate");
|
||||
return;
|
||||
}
|
||||
if (event.event === "chat.side_result") {
|
||||
if (handleChatSideResultGatewayEvent(state as unknown as ChatState, event.payload)) {
|
||||
requestChatPageUpdate(state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.event === "session.observer") {
|
||||
const payload = event.payload as SessionObserverDigest | undefined;
|
||||
if (
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
type ChatModelControlsProps,
|
||||
} from "./components/chat-model-controls.ts";
|
||||
import {
|
||||
isChatThreadSearchOpen,
|
||||
resetChatThreadPresentationState,
|
||||
toggleChatThreadSearch,
|
||||
} from "./components/chat-thread.ts";
|
||||
@@ -637,7 +636,6 @@ function createChatProps(
|
||||
compactionStatus: null,
|
||||
fallbackStatus: null,
|
||||
messages: [],
|
||||
sideChatTurns: [],
|
||||
toolMessages: [],
|
||||
streamSegments: [],
|
||||
stream: null,
|
||||
@@ -684,8 +682,6 @@ function createChatProps(
|
||||
onAbort: () => undefined,
|
||||
onQueueRemove: () => undefined,
|
||||
onQueueSteer: () => undefined,
|
||||
onSideChatClose: () => undefined,
|
||||
onSideChatClear: () => undefined,
|
||||
onNewSession: () => undefined,
|
||||
onClearHistory: () => undefined,
|
||||
onOpenSessionCheckpoints: () => undefined,
|
||||
@@ -1958,15 +1954,24 @@ describe("per-pane chat presentation state", () => {
|
||||
});
|
||||
|
||||
it("keeps thread search independent and resets only the targeted pane", () => {
|
||||
const paneA = document.createElement("div");
|
||||
const paneB = document.createElement("div");
|
||||
const renderPane = (container: HTMLElement, paneId: string, draft: string) => {
|
||||
render(renderChat(createChatProps({ paneId, draft, getDraft: () => draft })), container);
|
||||
};
|
||||
|
||||
toggleChatThreadSearch("pane-a", vi.fn());
|
||||
expect(isChatThreadSearchOpen("pane-a")).toBe(true);
|
||||
expect(isChatThreadSearchOpen("pane-b")).toBe(false);
|
||||
renderPane(paneA, "pane-a", "");
|
||||
renderPane(paneB, "pane-b", "");
|
||||
expect(paneA.querySelector(".agent-chat__search-bar")).not.toBeNull();
|
||||
expect(paneB.querySelector(".agent-chat__search-bar")).toBeNull();
|
||||
|
||||
toggleChatThreadSearch("pane-b", vi.fn());
|
||||
resetChatThreadPresentationState("pane-a");
|
||||
|
||||
expect(isChatThreadSearchOpen("pane-a")).toBe(false);
|
||||
expect(isChatThreadSearchOpen("pane-b")).toBe(true);
|
||||
renderPane(paneA, "pane-a", "");
|
||||
renderPane(paneB, "pane-b", "");
|
||||
expect(paneA.querySelector(".agent-chat__search-bar")).toBeNull();
|
||||
expect(paneB.querySelector(".agent-chat__search-bar")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ import type {
|
||||
SessionSuggestionResolution,
|
||||
TaskSuggestion,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
SessionObserverDigest,
|
||||
SessionsObserverAskResult,
|
||||
} from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type {
|
||||
ControlUiSessionBranch,
|
||||
ControlUiSessionPullRequest,
|
||||
@@ -32,11 +29,11 @@ import type {
|
||||
ChatStreamSegment,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
|
||||
import type { ChatSideResult, ChatSideResultPending } from "../../lib/chat/side-result.ts";
|
||||
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
|
||||
import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts";
|
||||
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
|
||||
import type { ChatRunStartupStatus } from "./chat-run-startup.ts";
|
||||
import type { ChatSessionCompanionThread } from "./chat-session-companion.ts";
|
||||
import { renderChatViewNotices } from "./chat-view-notices.ts";
|
||||
import { createChatAttachmentDropHandlers } from "./components/chat-attachments.ts";
|
||||
import {
|
||||
@@ -51,13 +48,13 @@ import {
|
||||
} from "./components/chat-image-lightbox.ts";
|
||||
import { renderChatPullRequests } from "./components/chat-pull-requests.ts";
|
||||
import { renderChatResizableDivider } from "./components/chat-resizable-divider.ts";
|
||||
import { renderChatSessionSuggestions } from "./components/chat-session-suggestions.ts";
|
||||
import "./components/chat-sidebar.ts";
|
||||
import type { SessionRailMode } from "./components/chat-session-rail.ts";
|
||||
import { renderChatSessionSuggestions } from "./components/chat-session-suggestions.ts";
|
||||
import {
|
||||
renderSessionWorkspaceRail,
|
||||
type SessionWorkspaceProps,
|
||||
} from "./components/chat-session-workspace.ts";
|
||||
import { isSideChatPanelVisible, renderSideChatPanel } from "./components/chat-side-chat.ts";
|
||||
import type {
|
||||
DetailFullMessageResult,
|
||||
SidebarContent,
|
||||
@@ -67,7 +64,6 @@ import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts";
|
||||
import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts";
|
||||
import {
|
||||
type ChatTranscriptController,
|
||||
isChatThreadSearchOpen,
|
||||
renderChatPinnedMessages,
|
||||
renderChatSearchBar,
|
||||
renderChatThread,
|
||||
@@ -110,21 +106,25 @@ export type ChatProps = {
|
||||
fallbackStatus?: FallbackStatus | null;
|
||||
planStatus?: PlanStatus | null;
|
||||
observerDigest?: SessionObserverDigest | null;
|
||||
observerHudReady?: boolean;
|
||||
sessionRailReady?: boolean;
|
||||
observerRunId?: string | null;
|
||||
observerStartedAt?: number;
|
||||
observerLastReadAt?: number;
|
||||
onObserverAsk?: (sessionKey: string, question: string) => Promise<SessionsObserverAskResult>;
|
||||
onObserverVisibilityChange?: (visible: boolean) => void;
|
||||
sessionRailCompanion?: ChatSessionCompanionThread;
|
||||
sessionRailOpenRequest?: number;
|
||||
sessionRailMode?: SessionRailMode;
|
||||
sessionRailDocked?: boolean;
|
||||
onSessionRailSubmit?: (question: string) => void;
|
||||
onSessionRailDraftChange?: (draft: string) => void;
|
||||
onSessionRailClear?: () => void;
|
||||
onSessionRailModeChange?: (mode: SessionRailMode) => void;
|
||||
gatewayQuestionPrompts?: readonly QuestionPrompt[];
|
||||
onGatewayQuestionChange?: () => void;
|
||||
onGatewayQuestionSubmit?: (id: string, answers: Record<string, string[]>) => void | Promise<void>;
|
||||
onGatewayQuestionSkip?: (id: string) => void | Promise<void>;
|
||||
messages: unknown[];
|
||||
historyPagination?: { loading: boolean };
|
||||
sideChatTurns?: ChatSideResult[];
|
||||
sideChatPending?: ChatSideResultPending | null;
|
||||
sideChatHidden?: boolean;
|
||||
toolMessages: unknown[];
|
||||
streamSegments: ChatStreamSegment[];
|
||||
stream: string | null;
|
||||
@@ -225,15 +225,8 @@ export type ChatProps = {
|
||||
onQueueSteer?: (id: string) => void;
|
||||
onGoalCommand?: (command: string) => void;
|
||||
onHistoryIntent?: (event: Event) => void;
|
||||
/** Sends a detached /btw side question (selection popup or side-chat
|
||||
* follow-up). `displayQuestion` overrides the pending-turn display when the
|
||||
* command embeds carried follow-up context; `onSendRejected` lets the panel
|
||||
* restore its typed follow-up when the detached send is not accepted. */
|
||||
onSideQuestion?: (command: string, displayQuestion?: string, onSendRejected?: () => void) => void;
|
||||
/** Hides the side-chat panel; the conversation (and a pending run) survives. */
|
||||
onSideChatClose?: () => void;
|
||||
/** Discards the side-chat conversation and retires any pending run. */
|
||||
onSideChatClear?: () => void;
|
||||
onCompanionQuestion?: (question: string) => void;
|
||||
onCompanionPrefill?: (question: string) => void;
|
||||
onNewSession: () => void;
|
||||
onClearHistory?: () => void;
|
||||
agentsList: {
|
||||
@@ -305,11 +298,6 @@ export function renderChat(props: ChatProps) {
|
||||
const tasksOpen = props.backgroundTasks?.collapsed === false;
|
||||
const tasksDockBottom = tasksOpen && props.backgroundTasks?.narrowLayout === true;
|
||||
const canCompose = props.canSend;
|
||||
const sideChatProps = {
|
||||
turns: props.sideChatTurns ?? [],
|
||||
pending: props.sideChatPending ?? null,
|
||||
hidden: props.sideChatHidden === true,
|
||||
};
|
||||
const openImage = props.onOpenImage
|
||||
? (item: ImageLightboxItem, requestVersion?: number) => {
|
||||
if (requestVersion === undefined) {
|
||||
@@ -322,7 +310,6 @@ export function renderChat(props: ChatProps) {
|
||||
const openImmediateImage = props.onOpenImage
|
||||
? (item: ImageLightboxItem) => openImage?.(item, props.onRequestOpenImage?.())
|
||||
: undefined;
|
||||
const sideChatVisible = isSideChatPanelVisible(sideChatProps);
|
||||
const attachmentDropHandlers = createChatAttachmentDropHandlers({ ...props, canCompose });
|
||||
let chatSection: HTMLElement | null = null;
|
||||
const thread = renderChatThread(
|
||||
@@ -377,14 +364,16 @@ export function renderChat(props: ChatProps) {
|
||||
onChatScroll: props.onChatScroll,
|
||||
onHistoryIntent: props.onHistoryIntent,
|
||||
onDraftChange: props.onDraftChange,
|
||||
getDraft: props.getDraft,
|
||||
onSend: props.onSend,
|
||||
onSetReply: props.onSetReply,
|
||||
onRewindMessage: props.onRewindMessage,
|
||||
onForkMessage: props.onForkMessage,
|
||||
// Archived/non-composable sessions must not offer selection actions:
|
||||
// withholding the callback keeps the popup from rendering at all.
|
||||
onSideQuestion: props.canSend && !props.suggestionComposer ? props.onSideQuestion : undefined,
|
||||
onCompanionQuestion:
|
||||
props.canSend && !props.suggestionComposer ? props.onCompanionQuestion : undefined,
|
||||
onCompanionPrefill:
|
||||
props.canSend && !props.suggestionComposer ? props.onCompanionPrefill : undefined,
|
||||
onOpenSession: props.onSessionSelect,
|
||||
backgroundTasks: props.backgroundTasks,
|
||||
onFocusComposer: () =>
|
||||
@@ -514,11 +503,6 @@ export function renderChat(props: ChatProps) {
|
||||
props.onClearReply?.();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && sideChatVisible && !isChatThreadSearchOpen(props.paneId)) {
|
||||
event.preventDefault();
|
||||
props.onSideChatClose?.();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key === "f") {
|
||||
event.preventDefault();
|
||||
toggleChatThreadSearch(props.paneId, requestUpdate);
|
||||
@@ -575,80 +559,79 @@ export function renderChat(props: ChatProps) {
|
||||
: ""} ${sidebarOpen && sidebarStacked ? "chat-split-container--stacked" : ""}"
|
||||
>
|
||||
<div
|
||||
class="chat-main"
|
||||
class="chat-main ${props.sessionRailDocked && props.sessionRailMode === "expanded"
|
||||
? "chat-main--rail-docked"
|
||||
: ""}"
|
||||
style="flex: ${sidebarOpen ? `0 1 ${splitRatio * 100}%` : "1 1 100%"}"
|
||||
>
|
||||
${thread}
|
||||
${props.inlineApproval && props.onApprovalDecision
|
||||
? html`<div class="chat-inline-approval">
|
||||
${renderExecApprovalCard({
|
||||
approval: props.inlineApproval,
|
||||
busy: props.approvalBusy === true,
|
||||
error: props.approvalErrors?.get(props.inlineApproval.id) ?? null,
|
||||
nowMs: props.approvalNowMs ?? Date.now(),
|
||||
variant: "inline",
|
||||
onDecision: props.onApprovalDecision,
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderChatTaskSuggestions({
|
||||
suggestions: props.taskSuggestions ?? [],
|
||||
busyIds: props.taskSuggestionBusyIds ?? new Set(),
|
||||
canAccept: props.canAcceptTaskSuggestions === true,
|
||||
canDismiss: props.canDismissTaskSuggestions === true,
|
||||
onAccept: (suggestion) => props.onAcceptTaskSuggestion?.(suggestion),
|
||||
onDismiss: (suggestion) => props.onDismissTaskSuggestion?.(suggestion),
|
||||
})}
|
||||
${renderChatPullRequests({
|
||||
pullRequests: props.pullRequests ?? [],
|
||||
branch: props.pullRequestsBranch,
|
||||
rateLimited: props.pullRequestsRateLimited === true,
|
||||
expanded: props.pullRequestsExpanded === true,
|
||||
onExpand: () => props.onExpandPullRequests?.(),
|
||||
onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest),
|
||||
})}
|
||||
${renderChatSessionSuggestions({
|
||||
suggestions: props.sessionSuggestions ?? [],
|
||||
role: props.sessionSuggestionRole,
|
||||
busyIds: props.sessionSuggestionBusyIds ?? new Set(),
|
||||
archived: props.sessionSuggestionsArchived === true,
|
||||
canResolve: props.canResolveSessionSuggestions === true,
|
||||
onResolve: (suggestion, resolution) =>
|
||||
props.onResolveSessionSuggestion?.(suggestion, resolution),
|
||||
})}
|
||||
${props.observerHudReady
|
||||
<div class="chat-main__conversation">
|
||||
${thread}
|
||||
${props.inlineApproval && props.onApprovalDecision
|
||||
? html`<div class="chat-inline-approval">
|
||||
${renderExecApprovalCard({
|
||||
approval: props.inlineApproval,
|
||||
busy: props.approvalBusy === true,
|
||||
error: props.approvalErrors?.get(props.inlineApproval.id) ?? null,
|
||||
nowMs: props.approvalNowMs ?? Date.now(),
|
||||
variant: "inline",
|
||||
onDecision: props.onApprovalDecision,
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderChatTaskSuggestions({
|
||||
suggestions: props.taskSuggestions ?? [],
|
||||
busyIds: props.taskSuggestionBusyIds ?? new Set(),
|
||||
canAccept: props.canAcceptTaskSuggestions === true,
|
||||
canDismiss: props.canDismissTaskSuggestions === true,
|
||||
onAccept: (suggestion) => props.onAcceptTaskSuggestion?.(suggestion),
|
||||
onDismiss: (suggestion) => props.onDismissTaskSuggestion?.(suggestion),
|
||||
})}
|
||||
${renderChatPullRequests({
|
||||
pullRequests: props.pullRequests ?? [],
|
||||
branch: props.pullRequestsBranch,
|
||||
rateLimited: props.pullRequestsRateLimited === true,
|
||||
expanded: props.pullRequestsExpanded === true,
|
||||
onExpand: () => props.onExpandPullRequests?.(),
|
||||
onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest),
|
||||
})}
|
||||
${renderChatSessionSuggestions({
|
||||
suggestions: props.sessionSuggestions ?? [],
|
||||
role: props.sessionSuggestionRole,
|
||||
busyIds: props.sessionSuggestionBusyIds ?? new Set(),
|
||||
archived: props.sessionSuggestionsArchived === true,
|
||||
canResolve: props.canResolveSessionSuggestions === true,
|
||||
onResolve: (suggestion, resolution) =>
|
||||
props.onResolveSessionSuggestion?.(suggestion, resolution),
|
||||
})}
|
||||
${scrollToBottomButton}
|
||||
${renderChatSwarmProgress({
|
||||
sessions: props.swarmSessions ?? [],
|
||||
sessionKey: props.sessionKey,
|
||||
})}
|
||||
${chatColumnFooter}
|
||||
</div>
|
||||
${props.sessionRailReady
|
||||
? html`
|
||||
<openclaw-chat-observer-hud
|
||||
<openclaw-chat-session-rail
|
||||
.sessionKey=${props.sessionKey}
|
||||
.digest=${props.observerDigest ?? null}
|
||||
.running=${Boolean(props.observerRunId)}
|
||||
.activeRunId=${props.observerRunId ?? null}
|
||||
.startedAt=${props.observerStartedAt}
|
||||
.lastReadAt=${props.observerLastReadAt}
|
||||
.sideChatOpen=${sideChatVisible}
|
||||
.planStatus=${props.planStatus ?? null}
|
||||
.pullRequests=${props.pullRequests ?? []}
|
||||
.onAsk=${props.onObserverAsk}
|
||||
.companion=${props.sessionRailCompanion}
|
||||
.connected=${props.connected}
|
||||
.openRequest=${props.sessionRailOpenRequest ?? 0}
|
||||
.onSubmit=${props.onSessionRailSubmit}
|
||||
.onDraftChange=${props.onSessionRailDraftChange}
|
||||
.onClear=${props.onSessionRailClear}
|
||||
.onModeChange=${props.onSessionRailModeChange}
|
||||
.onVisibilityChange=${props.onObserverVisibilityChange}
|
||||
></openclaw-chat-observer-hud>
|
||||
></openclaw-chat-session-rail>
|
||||
`
|
||||
: nothing}
|
||||
${scrollToBottomButton}
|
||||
${renderChatSwarmProgress({
|
||||
sessions: props.swarmSessions ?? [],
|
||||
sessionKey: props.sessionKey,
|
||||
})}
|
||||
${chatColumnFooter}
|
||||
${renderSideChatPanel({
|
||||
...sideChatProps,
|
||||
// Detached slash sends are refused while disconnected (see
|
||||
// canSubmitDraft); hide the input instead of eating drafts.
|
||||
canFollowUp:
|
||||
canCompose && props.connected && typeof props.onSideQuestion === "function",
|
||||
onFollowUp: props.onSideQuestion,
|
||||
onClose: props.onSideChatClose,
|
||||
onClear: props.onSideChatClear,
|
||||
})}
|
||||
</div>
|
||||
|
||||
${sidebarOpen
|
||||
|
||||
@@ -1,566 +0,0 @@
|
||||
import { html, nothing, type PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type {
|
||||
SessionObserverDigest,
|
||||
SessionsObserverAskResult,
|
||||
} from "../../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { ControlUiSessionPullRequest } from "../../../../../src/gateway/control-ui-contract.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { formatDurationCompact } from "../../../lib/format.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import {
|
||||
type ChatObserverDisplayPreference,
|
||||
loadChatObserverDisplayPreference,
|
||||
storeChatObserverDisplayPreference,
|
||||
} from "../chat-observer-display.ts";
|
||||
import type { PlanStatus } from "../tool-stream.ts";
|
||||
|
||||
const MAX_ASK_EXCHANGES = 6;
|
||||
const OBSERVER_BUSY_DETAIL_CODE = "SESSION_OBSERVER_BUSY";
|
||||
|
||||
export type ObserverHudMode = "hidden" | "restore" | "pill" | "card";
|
||||
export type ObserverAskHint = "busy" | "unavailable";
|
||||
export type ObserverAskExchange = {
|
||||
question: string;
|
||||
answer?: string;
|
||||
hint?: ObserverAskHint;
|
||||
};
|
||||
|
||||
function errorDetailCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") {
|
||||
return null;
|
||||
}
|
||||
const details = (error as { details?: unknown }).details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = (details as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : null;
|
||||
}
|
||||
|
||||
export class ChatObserverAskState {
|
||||
sessionKey = "";
|
||||
exchanges: ObserverAskExchange[] = [];
|
||||
pending = false;
|
||||
private generation = 0;
|
||||
|
||||
switchSession(sessionKey: string): void {
|
||||
if (sessionKey === this.sessionKey) {
|
||||
return;
|
||||
}
|
||||
this.sessionKey = sessionKey;
|
||||
this.exchanges = [];
|
||||
this.pending = false;
|
||||
this.generation += 1;
|
||||
}
|
||||
|
||||
async submit(
|
||||
question: string,
|
||||
ask: (sessionKey: string, question: string) => Promise<SessionsObserverAskResult>,
|
||||
): Promise<void> {
|
||||
const normalized = question.trim();
|
||||
if (!normalized || !this.sessionKey || this.pending) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = this.sessionKey;
|
||||
const generation = this.generation;
|
||||
const exchange: ObserverAskExchange = { question: normalized };
|
||||
this.exchanges = [...this.exchanges, exchange].slice(-MAX_ASK_EXCHANGES);
|
||||
this.pending = true;
|
||||
try {
|
||||
const result = await ask(sessionKey, normalized);
|
||||
if (generation === this.generation && sessionKey === this.sessionKey) {
|
||||
exchange.answer = result.answer;
|
||||
this.exchanges = [...this.exchanges];
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.generation && sessionKey === this.sessionKey) {
|
||||
exchange.hint =
|
||||
errorDetailCode(error) === OBSERVER_BUSY_DETAIL_CODE ? "busy" : "unavailable";
|
||||
this.exchanges = [...this.exchanges];
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.generation && sessionKey === this.sessionKey) {
|
||||
this.pending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ObserverHudInput = {
|
||||
running: boolean;
|
||||
activeRunId: string | null;
|
||||
digest: SessionObserverDigest | null;
|
||||
lastReadAt?: number;
|
||||
sideChatOpen: boolean;
|
||||
};
|
||||
|
||||
function visibleDigest(input: ObserverHudInput): SessionObserverDigest | null {
|
||||
if (!input.digest) {
|
||||
return null;
|
||||
}
|
||||
if (!input.running) {
|
||||
return input.digest;
|
||||
}
|
||||
return input.activeRunId && input.digest.runId === input.activeRunId ? input.digest : null;
|
||||
}
|
||||
|
||||
function unreadFinalDigest(digest: SessionObserverDigest, lastReadAt?: number): boolean {
|
||||
return (
|
||||
(digest.health === "done" || digest.health === "failed") && (lastReadAt ?? 0) < digest.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
/** State owner for preference, once-per-run critical expansion, and side-chat yield. */
|
||||
export class ChatObserverHudState {
|
||||
private autoExpandedRunIds = new Set<string>();
|
||||
private autoExpandedRunId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private displayPreference: ChatObserverDisplayPreference = loadChatObserverDisplayPreference(),
|
||||
) {}
|
||||
|
||||
mode(input: ObserverHudInput): ObserverHudMode {
|
||||
const digest = visibleDigest(input);
|
||||
const renderable =
|
||||
digest !== null && (input.running || unreadFinalDigest(digest, input.lastReadAt));
|
||||
if (this.displayPreference === "off") {
|
||||
this.autoExpandedRunId = null;
|
||||
// A running chat keeps the restore control even before any digest exists:
|
||||
// hidden visibility stops generation, so waiting for a digest would leave
|
||||
// no rendered way to turn the observer back on.
|
||||
return input.running || renderable ? "restore" : "hidden";
|
||||
}
|
||||
if (!renderable) {
|
||||
// The transient critical-expansion latch must not survive the HUD hiding,
|
||||
// or a later benign digest under a reused run id reopens as a card.
|
||||
this.autoExpandedRunId = null;
|
||||
return "hidden";
|
||||
}
|
||||
const runId = input.activeRunId ?? digest.runId ?? null;
|
||||
const critical = digest.health === "stuck" || digest.health === "waiting-on-user";
|
||||
if (critical && runId && !this.autoExpandedRunIds.has(runId)) {
|
||||
this.autoExpandedRunIds.add(runId);
|
||||
this.autoExpandedRunId = runId;
|
||||
}
|
||||
if (input.sideChatOpen) {
|
||||
return "pill";
|
||||
}
|
||||
return this.displayPreference === "card" || (runId !== null && this.autoExpandedRunId === runId)
|
||||
? "card"
|
||||
: "pill";
|
||||
}
|
||||
|
||||
expand(): void {
|
||||
this.displayPreference = "card";
|
||||
this.autoExpandedRunId = null;
|
||||
storeChatObserverDisplayPreference("card");
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.displayPreference = "pill";
|
||||
this.autoExpandedRunId = null;
|
||||
storeChatObserverDisplayPreference("pill");
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.displayPreference = "off";
|
||||
this.autoExpandedRunId = null;
|
||||
storeChatObserverDisplayPreference("off");
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.displayPreference = "pill";
|
||||
this.autoExpandedRunId = null;
|
||||
storeChatObserverDisplayPreference("pill");
|
||||
}
|
||||
}
|
||||
|
||||
function healthLabel(health: SessionObserverDigest["health"]): string {
|
||||
return t(`chat.observer.health.${health}` as Parameters<typeof t>[0]);
|
||||
}
|
||||
|
||||
function prStateLabel(pullRequestState: ControlUiSessionPullRequest["state"]): string {
|
||||
return t(
|
||||
`chat.pullRequests.${pullRequestState === "draft" ? "draft" : pullRequestState}` as Parameters<
|
||||
typeof t
|
||||
>[0],
|
||||
);
|
||||
}
|
||||
|
||||
function checksSummary(pullRequest: ControlUiSessionPullRequest): string | null {
|
||||
const checks = pullRequest.checks;
|
||||
if (!checks) {
|
||||
return null;
|
||||
}
|
||||
if (checks.state === "passing") {
|
||||
return t("chat.observer.checksPassing", { count: String(checks.passed) });
|
||||
}
|
||||
if (checks.state === "failing") {
|
||||
return t("chat.observer.checksFailing", { count: String(checks.failed) });
|
||||
}
|
||||
return t("chat.observer.checksPending", { count: String(checks.running) });
|
||||
}
|
||||
|
||||
function renderPlanStep(step: PlanStatus["steps"][number]) {
|
||||
const icon = step.status === "completed" ? "✓" : step.status === "in_progress" ? "→" : "·";
|
||||
return html`
|
||||
<li class="chat-observer-hud__plan-item" data-status=${step.status}>
|
||||
<span class="chat-observer-hud__plan-icon" aria-hidden="true">${icon}</span>
|
||||
<span>${step.step}</span>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
export class ChatObserverHudElement extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) digest: SessionObserverDigest | null = null;
|
||||
@property({ attribute: false }) running = false;
|
||||
@property({ attribute: false }) activeRunId: string | null = null;
|
||||
@property({ attribute: false }) startedAt?: number;
|
||||
@property({ attribute: false }) lastReadAt?: number;
|
||||
@property({ attribute: false }) sideChatOpen = false;
|
||||
@property({ attribute: false }) planStatus: PlanStatus | null = null;
|
||||
@property({ attribute: false }) pullRequests: ControlUiSessionPullRequest[] = [];
|
||||
@property({ attribute: false }) onAsk?: (
|
||||
sessionKey: string,
|
||||
question: string,
|
||||
) => Promise<SessionsObserverAskResult>;
|
||||
@property({ attribute: false }) onVisibilityChange?: (visible: boolean) => void;
|
||||
@state() private now = Date.now();
|
||||
@state() private question = "";
|
||||
@state() private askRevision = 0;
|
||||
|
||||
private readonly hudState = new ChatObserverHudState();
|
||||
private readonly askState = new ChatObserverAskState();
|
||||
private clock: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.stopClock();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("sessionKey")) {
|
||||
this.askState.switchSession(this.sessionKey);
|
||||
this.question = "";
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
if (this.running && this.startedAt != null && visibleDigest(this.input())) {
|
||||
this.scheduleClock();
|
||||
} else {
|
||||
this.stopClock();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleClock() {
|
||||
if (this.clock !== null) {
|
||||
return;
|
||||
}
|
||||
this.clock = globalThis.setTimeout(() => {
|
||||
this.clock = null;
|
||||
this.now = Date.now();
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
private stopClock() {
|
||||
if (this.clock !== null) {
|
||||
globalThis.clearTimeout(this.clock);
|
||||
this.clock = null;
|
||||
}
|
||||
}
|
||||
|
||||
private input(): ObserverHudInput {
|
||||
return {
|
||||
running: this.running,
|
||||
activeRunId: this.activeRunId,
|
||||
digest: this.digest,
|
||||
lastReadAt: this.lastReadAt,
|
||||
sideChatOpen: this.sideChatOpen,
|
||||
};
|
||||
}
|
||||
|
||||
private collapse() {
|
||||
this.hudState.collapse();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private expand() {
|
||||
this.hudState.expand();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private hide() {
|
||||
this.hudState.hide();
|
||||
this.onVisibilityChange?.(false);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private show() {
|
||||
this.hudState.show();
|
||||
this.onVisibilityChange?.(true);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderStatus(health: SessionObserverDigest["health"], label: string) {
|
||||
return html`
|
||||
<span class="chat-observer-hud__status" data-health=${health}>
|
||||
<span class="chat-observer-hud__status-dot"></span>${label}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private async submitQuestion() {
|
||||
const question = this.question.trim();
|
||||
if (!question || !this.onAsk || this.askState.pending) {
|
||||
return;
|
||||
}
|
||||
this.question = "";
|
||||
const pending = this.askState.submit(question, this.onAsk);
|
||||
this.askRevision += 1;
|
||||
await pending;
|
||||
this.askRevision += 1;
|
||||
}
|
||||
|
||||
private renderAskThread() {
|
||||
// Reading the revision makes mutations in the deliberately small state
|
||||
// machine visible to Lit without moving this client-only thread upstream.
|
||||
void this.askRevision;
|
||||
if (this.askState.exchanges.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-observer-hud__ask-thread" aria-live="polite">
|
||||
${this.askState.exchanges.map(
|
||||
(exchange, index) => html`
|
||||
<div class="chat-observer-hud__ask-exchange">
|
||||
<div class="chat-observer-hud__ask-question">${exchange.question}</div>
|
||||
${exchange.answer
|
||||
? html`<div class="chat-observer-hud__ask-answer">${exchange.answer}</div>`
|
||||
: exchange.hint
|
||||
? html`<div class="chat-observer-hud__ask-hint">
|
||||
${t(
|
||||
exchange.hint === "busy"
|
||||
? "chat.observer.askBusy"
|
||||
: "chat.observer.askUnavailable",
|
||||
)}
|
||||
</div>`
|
||||
: this.askState.pending && index === this.askState.exchanges.length - 1
|
||||
? html`<div class="chat-observer-hud__ask-hint">
|
||||
${t("chat.observer.askPending")}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPullRequests() {
|
||||
const pullRequests = this.pullRequests.slice(0, 2);
|
||||
if (pullRequests.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-observer-hud__prs" aria-label=${t("chat.observer.pullRequests")}>
|
||||
${pullRequests.map((pullRequest) => {
|
||||
const checks = checksSummary(pullRequest);
|
||||
return html`
|
||||
<a
|
||||
class="chat-observer-hud__pr"
|
||||
href=${pullRequest.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title=${pullRequest.title}
|
||||
>
|
||||
<span>#${pullRequest.number}</span>
|
||||
<span>${prStateLabel(pullRequest.state)}</span>
|
||||
${checks
|
||||
? html`<span class="chat-observer-hud__pr-checks">${checks}</span>`
|
||||
: nothing}
|
||||
</a>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const input = this.input();
|
||||
const mode = this.hudState.mode(input);
|
||||
if (mode === "hidden") {
|
||||
return nothing;
|
||||
}
|
||||
if (mode === "restore") {
|
||||
// Renders digest-free: while hidden, generation is off and a running chat
|
||||
// may never receive one.
|
||||
return html`
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-observer-hud chat-observer-hud--restore"
|
||||
type="button"
|
||||
aria-label=${t("chat.observer.show")}
|
||||
title=${t("chat.observer.show")}
|
||||
@click=${() => this.show()}
|
||||
>
|
||||
${icons.activity}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
const digest = visibleDigest(input);
|
||||
if (!digest) {
|
||||
return nothing;
|
||||
}
|
||||
const headline = digest.headline;
|
||||
const health = digest.health;
|
||||
const label = healthLabel(health);
|
||||
if (mode === "pill") {
|
||||
return html`
|
||||
<div class="chat-observer-hud chat-observer-hud--pill" aria-live="polite">
|
||||
${this.renderStatus(health, label)}
|
||||
<button
|
||||
class="chat-observer-hud__expand"
|
||||
type="button"
|
||||
aria-label=${t("chat.observer.expand")}
|
||||
@click=${() => this.expand()}
|
||||
>
|
||||
<span class="chat-observer-hud__headline">${headline}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-observer-hud__hide"
|
||||
type="button"
|
||||
aria-label=${t("chat.observer.hide")}
|
||||
@click=${() => this.hide()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-observer-hud__toggle"
|
||||
type="button"
|
||||
aria-label=${t("chat.observer.expand")}
|
||||
@click=${() => this.expand()}
|
||||
>
|
||||
${icons.chevronDown}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const elapsed =
|
||||
this.startedAt == null ? null : formatDurationCompact(Math.max(0, this.now - this.startedAt));
|
||||
const progress = digest.planProgress;
|
||||
const steps = this.planStatus?.steps.slice(-3) ?? [];
|
||||
return html`
|
||||
<section
|
||||
class="chat-observer-hud chat-observer-hud--card"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label=${t("chat.observer.title")}
|
||||
tabindex="-1"
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
this.collapse();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header class="chat-observer-hud__header">
|
||||
${this.renderStatus(health, label)}
|
||||
<strong class="chat-observer-hud__headline">${headline}</strong>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-observer-hud__hide"
|
||||
type="button"
|
||||
aria-label=${t("chat.observer.hide")}
|
||||
@click=${() => this.hide()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-observer-hud__toggle"
|
||||
type="button"
|
||||
aria-label=${t("chat.observer.collapse")}
|
||||
@click=${() => this.collapse()}
|
||||
>
|
||||
${icons.chevronUp}
|
||||
</button>
|
||||
</header>
|
||||
${digest.assessment
|
||||
? html`<p class="chat-observer-hud__assessment">${digest.assessment}</p>`
|
||||
: nothing}
|
||||
${progress || steps.length > 0
|
||||
? html`
|
||||
<div class="chat-observer-hud__plan">
|
||||
<div class="chat-observer-hud__plan-heading">
|
||||
<span>${t("chat.observer.plan")}</span>
|
||||
${progress
|
||||
? html`<span
|
||||
>${t("chat.observer.progress", {
|
||||
completed: String(progress.completed),
|
||||
total: String(progress.total),
|
||||
})}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
${steps.length > 0
|
||||
? html`<ul class="chat-observer-hud__plan-list">
|
||||
${steps.map(renderPlanStep)}
|
||||
</ul>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this.renderPullRequests()}
|
||||
${this.running || elapsed
|
||||
? html`
|
||||
<footer class="chat-observer-hud__footer">
|
||||
${this.running
|
||||
? html`<span class="chat-observer-hud__run-dot" data-running></span>
|
||||
<span>${t("chat.observer.running")}</span>`
|
||||
: nothing}
|
||||
${this.running && elapsed ? html`<span aria-hidden="true">·</span>` : nothing}
|
||||
${elapsed ? html`<span>${elapsed}</span>` : nothing}
|
||||
</footer>
|
||||
`
|
||||
: nothing}
|
||||
${this.renderAskThread()}
|
||||
<form
|
||||
class="chat-observer-hud__ask-form"
|
||||
@submit=${(event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
void this.submitQuestion();
|
||||
}}
|
||||
>
|
||||
<label class="chat-observer-hud__ask-field">
|
||||
<span class="sr-only">${t("chat.observer.askLabel")}</span>
|
||||
<input
|
||||
class="chat-observer-hud__ask-input"
|
||||
type="text"
|
||||
maxlength="400"
|
||||
autocomplete="off"
|
||||
.value=${this.question}
|
||||
placeholder=${t("chat.observer.askPlaceholder")}
|
||||
?disabled=${this.askState.pending || !this.onAsk}
|
||||
@input=${(event: InputEvent) => {
|
||||
this.question = (event.currentTarget as HTMLInputElement).value;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--ghost chat-observer-hud__ask-submit"
|
||||
type="submit"
|
||||
?disabled=${this.askState.pending || !this.question.trim() || !this.onAsk}
|
||||
>
|
||||
${t("chat.observer.askSubmit")}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-chat-observer-hud")) {
|
||||
customElements.define("openclaw-chat-observer-hud", ChatObserverHudElement);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Floating toolbar over selected chat text: "More details" fires an implicit
|
||||
// /btw side question; "Ask in side chat" pre-fills the composer with a /btw
|
||||
// draft quoting the selection. Mirrors the imperative reply-context-menu
|
||||
// pattern in chat-thread.ts (body-portaled fixed div, document-level dismiss).
|
||||
// Floating toolbar over selected chat text: "More details" asks the session
|
||||
// companion immediately; "Ask in side chat" pre-fills the session rail.
|
||||
// Mirrors the imperative reply-context-menu pattern in chat-thread.ts.
|
||||
|
||||
type ChatSelectionPopupActions = {
|
||||
onMoreDetails: (selection: string) => void;
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import type { SessionObserverDigest } from "../../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { ControlUiSessionPullRequest } from "../../../../../src/gateway/control-ui-contract.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { formatDurationCompact, formatTimeAgo, formatTimeMs } from "../../../lib/format.ts";
|
||||
import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import {
|
||||
type ChatObserverDisplayPreference,
|
||||
loadChatObserverDisplayPreference,
|
||||
storeChatObserverDisplayPreference,
|
||||
} from "../chat-observer-display.ts";
|
||||
import type { ChatSessionCompanionThread } from "../chat-session-companion.ts";
|
||||
import type { PlanStatus } from "../tool-stream.ts";
|
||||
|
||||
export type SessionRailMode = "hidden" | "restore-icon" | "pill" | "expanded";
|
||||
|
||||
export type SessionRailInput = {
|
||||
running: boolean;
|
||||
activeRunId: string | null;
|
||||
digest: SessionObserverDigest | null;
|
||||
lastReadAt?: number;
|
||||
hasCompanionActivity: boolean;
|
||||
};
|
||||
|
||||
function visibleDigest(input: SessionRailInput): SessionObserverDigest | null {
|
||||
if (!input.digest) {
|
||||
return null;
|
||||
}
|
||||
if (!input.running) {
|
||||
return input.digest;
|
||||
}
|
||||
return input.activeRunId && input.digest.runId === input.activeRunId ? input.digest : null;
|
||||
}
|
||||
|
||||
function unreadFinalDigest(digest: SessionObserverDigest, lastReadAt?: number): boolean {
|
||||
return (
|
||||
(digest.health === "done" || digest.health === "failed") && (lastReadAt ?? 0) < digest.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
/** State owner for the persisted rail preference and once-per-run critical expansion. */
|
||||
export class ChatSessionRailState {
|
||||
private autoExpandedRunIds = new Set<string>();
|
||||
private autoExpandedRunId: string | null = null;
|
||||
// Explicit open from the restore icon while idle: the companion must stay
|
||||
// reachable at any point, even with no digest and an empty thread.
|
||||
private manualOpen = false;
|
||||
|
||||
constructor(
|
||||
private displayPreference: ChatObserverDisplayPreference = loadChatObserverDisplayPreference(),
|
||||
) {}
|
||||
|
||||
resetManualOpen(): void {
|
||||
this.manualOpen = false;
|
||||
}
|
||||
|
||||
mode(input: SessionRailInput): SessionRailMode {
|
||||
const digest = visibleDigest(input);
|
||||
const digestRenderable =
|
||||
digest !== null && (input.running || unreadFinalDigest(digest, input.lastReadAt));
|
||||
const renderable = digestRenderable || input.hasCompanionActivity || this.manualOpen;
|
||||
if (this.displayPreference === "off") {
|
||||
this.autoExpandedRunId = null;
|
||||
return "restore-icon";
|
||||
}
|
||||
if (!renderable) {
|
||||
this.autoExpandedRunId = null;
|
||||
// Idle sessions keep the low-noise restore icon so companion questions
|
||||
// stay one click away after a run's final digest has been read.
|
||||
return "restore-icon";
|
||||
}
|
||||
const runId = input.activeRunId ?? digest?.runId ?? null;
|
||||
const critical = digest?.health === "stuck" || digest?.health === "waiting-on-user";
|
||||
if (critical && runId && !this.autoExpandedRunIds.has(runId)) {
|
||||
this.autoExpandedRunIds.add(runId);
|
||||
this.autoExpandedRunId = runId;
|
||||
}
|
||||
return this.displayPreference === "card" || (runId !== null && this.autoExpandedRunId === runId)
|
||||
? "expanded"
|
||||
: "pill";
|
||||
}
|
||||
|
||||
expand(): void {
|
||||
this.displayPreference = "card";
|
||||
this.autoExpandedRunId = null;
|
||||
storeChatObserverDisplayPreference("card");
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.displayPreference = "pill";
|
||||
this.autoExpandedRunId = null;
|
||||
storeChatObserverDisplayPreference("pill");
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.displayPreference = "off";
|
||||
this.autoExpandedRunId = null;
|
||||
this.manualOpen = false;
|
||||
storeChatObserverDisplayPreference("off");
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.displayPreference = "pill";
|
||||
this.autoExpandedRunId = null;
|
||||
this.manualOpen = true;
|
||||
storeChatObserverDisplayPreference("pill");
|
||||
}
|
||||
}
|
||||
|
||||
function healthLabel(health: SessionObserverDigest["health"]): string {
|
||||
return t(`chat.rail.health.${health}` as Parameters<typeof t>[0]);
|
||||
}
|
||||
|
||||
function prStateLabel(pullRequestState: ControlUiSessionPullRequest["state"]): string {
|
||||
return t(
|
||||
`chat.pullRequests.${pullRequestState === "draft" ? "draft" : pullRequestState}` as Parameters<
|
||||
typeof t
|
||||
>[0],
|
||||
);
|
||||
}
|
||||
|
||||
function checksSummary(pullRequest: ControlUiSessionPullRequest): string | null {
|
||||
const checks = pullRequest.checks;
|
||||
if (!checks) {
|
||||
return null;
|
||||
}
|
||||
if (checks.state === "passing") {
|
||||
return t("chat.rail.checksPassing", { count: String(checks.passed) });
|
||||
}
|
||||
if (checks.state === "failing") {
|
||||
return t("chat.rail.checksFailing", { count: String(checks.failed) });
|
||||
}
|
||||
return t("chat.rail.checksPending", { count: String(checks.running) });
|
||||
}
|
||||
|
||||
function renderPlanStep(step: PlanStatus["steps"][number]) {
|
||||
const icon = step.status === "completed" ? "✓" : step.status === "in_progress" ? "→" : "·";
|
||||
return html`
|
||||
<li class="chat-session-rail__plan-item" data-status=${step.status}>
|
||||
<span class="chat-session-rail__plan-icon" aria-hidden="true">${icon}</span>
|
||||
<span>${step.step}</span>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function companionHasActivity(thread: ChatSessionCompanionThread): boolean {
|
||||
return (
|
||||
thread.exchanges.length > 0 ||
|
||||
thread.pendingQuestion !== null ||
|
||||
thread.failedQuestion !== null ||
|
||||
thread.draft.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export class ChatSessionRailElement extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) digest: SessionObserverDigest | null = null;
|
||||
@property({ attribute: false }) running = false;
|
||||
@property({ attribute: false }) activeRunId: string | null = null;
|
||||
@property({ attribute: false }) startedAt?: number;
|
||||
@property({ attribute: false }) lastReadAt?: number;
|
||||
@property({ attribute: false }) planStatus: PlanStatus | null = null;
|
||||
@property({ attribute: false }) pullRequests: ControlUiSessionPullRequest[] = [];
|
||||
@property({ attribute: false }) companion: ChatSessionCompanionThread = {
|
||||
exchanges: [],
|
||||
pendingQuestion: null,
|
||||
failedQuestion: null,
|
||||
hint: null,
|
||||
draft: "",
|
||||
};
|
||||
@property({ attribute: false }) connected = false;
|
||||
@property({ attribute: false }) openRequest = 0;
|
||||
@property({ attribute: false }) onSubmit?: (question: string) => void;
|
||||
@property({ attribute: false }) onDraftChange?: (draft: string) => void;
|
||||
@property({ attribute: false }) onClear?: () => void;
|
||||
@property({ attribute: false }) onModeChange?: (mode: SessionRailMode) => void;
|
||||
@property({ attribute: false }) onVisibilityChange?: (visible: boolean) => void;
|
||||
@state() private now = Date.now();
|
||||
|
||||
private readonly railState = new ChatSessionRailState();
|
||||
private clock: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
private renderedMode: SessionRailMode = "hidden";
|
||||
private reportedMode: SessionRailMode | null = null;
|
||||
private terminalAgeReference = Date.now();
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.stopClock();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("sessionKey")) {
|
||||
this.terminalAgeReference = Date.now();
|
||||
// A manual idle-open is a per-session gesture; it must not leak the
|
||||
// rail open into the next selected session.
|
||||
this.railState.resetManualOpen();
|
||||
}
|
||||
if (changedProperties.has("digest") && this.digest) {
|
||||
if (this.digest.health === "done" || this.digest.health === "failed") {
|
||||
this.terminalAgeReference = Date.now();
|
||||
}
|
||||
}
|
||||
if (changedProperties.has("openRequest") && this.openRequest > 0) {
|
||||
this.railState.expand();
|
||||
this.onVisibilityChange?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
if (this.running && this.startedAt != null && visibleDigest(this.input())) {
|
||||
this.scheduleClock();
|
||||
} else {
|
||||
this.stopClock();
|
||||
}
|
||||
if (this.reportedMode !== this.renderedMode) {
|
||||
this.reportedMode = this.renderedMode;
|
||||
this.onModeChange?.(this.renderedMode);
|
||||
}
|
||||
}
|
||||
|
||||
private input(): SessionRailInput {
|
||||
return {
|
||||
running: this.running,
|
||||
activeRunId: this.activeRunId,
|
||||
digest: this.digest,
|
||||
lastReadAt: this.lastReadAt,
|
||||
hasCompanionActivity: companionHasActivity(this.companion) || this.openRequest > 0,
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleClock() {
|
||||
if (this.clock !== null) {
|
||||
return;
|
||||
}
|
||||
this.clock = globalThis.setTimeout(() => {
|
||||
this.clock = null;
|
||||
this.now = Date.now();
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
private stopClock() {
|
||||
if (this.clock !== null) {
|
||||
globalThis.clearTimeout(this.clock);
|
||||
this.clock = null;
|
||||
}
|
||||
}
|
||||
|
||||
private collapse() {
|
||||
this.railState.collapse();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private expand() {
|
||||
this.railState.expand();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private hide() {
|
||||
this.railState.hide();
|
||||
this.onVisibilityChange?.(false);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private show() {
|
||||
this.railState.show();
|
||||
this.onVisibilityChange?.(true);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private submit() {
|
||||
const question = this.companion.draft.trim();
|
||||
if (!question || !this.connected || this.companion.pendingQuestion || !this.onSubmit) {
|
||||
return;
|
||||
}
|
||||
this.onSubmit(question);
|
||||
}
|
||||
|
||||
private renderStatus(digest: SessionObserverDigest): TemplateResult {
|
||||
const terminal = digest.health === "done" || digest.health === "failed";
|
||||
const critical = digest.health === "stuck" || digest.health === "waiting-on-user";
|
||||
return html`
|
||||
<span
|
||||
class="chat-session-rail__status ${critical ? "chat-session-rail__status--critical" : ""}"
|
||||
data-health=${digest.health}
|
||||
>
|
||||
${terminal
|
||||
? html`<span class="chat-session-rail__status-icon" aria-hidden="true"
|
||||
>${digest.health === "done" ? icons.check : icons.x}</span
|
||||
>`
|
||||
: html`<span class="chat-session-rail__status-dot" aria-hidden="true"></span>`}
|
||||
<span>${healthLabel(digest.health)}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPullRequests() {
|
||||
const pullRequests = this.pullRequests.slice(0, 2);
|
||||
if (pullRequests.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-session-rail__prs" aria-label=${t("chat.rail.pullRequests")}>
|
||||
${pullRequests.map((pullRequest) => {
|
||||
const checks = checksSummary(pullRequest);
|
||||
return html`
|
||||
<a
|
||||
class="chat-session-rail__pr"
|
||||
href=${pullRequest.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title=${pullRequest.title}
|
||||
>
|
||||
<span>#${pullRequest.number}</span>
|
||||
<span>${prStateLabel(pullRequest.state)}</span>
|
||||
${checks
|
||||
? html`<span class="chat-session-rail__pr-checks">${checks}</span>`
|
||||
: nothing}
|
||||
</a>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDigestDetails(digest: SessionObserverDigest | null) {
|
||||
if (!digest) {
|
||||
return nothing;
|
||||
}
|
||||
const progress = digest.planProgress;
|
||||
const steps = this.planStatus?.steps.slice(-3) ?? [];
|
||||
return html`
|
||||
${digest.assessment
|
||||
? html`<p class="chat-session-rail__assessment">${digest.assessment}</p>`
|
||||
: nothing}
|
||||
${progress || steps.length > 0
|
||||
? html`
|
||||
<div class="chat-session-rail__plan">
|
||||
<div class="chat-session-rail__plan-heading">
|
||||
<span>${t("chat.rail.plan")}</span>
|
||||
${progress
|
||||
? html`<span
|
||||
>${t("chat.rail.progress", {
|
||||
completed: String(progress.completed),
|
||||
total: String(progress.total),
|
||||
})}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
${steps.length > 0
|
||||
? html`<ul class="chat-session-rail__plan-list">
|
||||
${steps.map(renderPlanStep)}
|
||||
</ul>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this.renderPullRequests()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderExchange(question: string, answer: string, ts: number) {
|
||||
return html`
|
||||
<article class="chat-session-rail__exchange">
|
||||
<div class="chat-session-rail__question" dir=${detectTextDirection(question)}>
|
||||
${question}
|
||||
</div>
|
||||
<div class="chat-session-rail__answer" dir=${detectTextDirection(answer)}>
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(answer))}
|
||||
</div>
|
||||
<time class="chat-session-rail__timestamp" datetime=${new Date(ts).toISOString()}>
|
||||
${t("chat.rail.asOf", {
|
||||
time: formatTimeMs(ts, { hour: "numeric", minute: "2-digit" }, ""),
|
||||
})}
|
||||
</time>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderThread() {
|
||||
const scrollKey = `${this.companion.exchanges.length}:${this.companion.pendingQuestion ?? ""}:${this.companion.failedQuestion ?? ""}`;
|
||||
const syncScroll = (element: Element | undefined) => {
|
||||
if (!(element instanceof HTMLElement) || element.dataset.railScrollKey === scrollKey) {
|
||||
return;
|
||||
}
|
||||
element.dataset.railScrollKey = scrollKey;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
};
|
||||
return html`
|
||||
<div class="chat-session-rail__thread" aria-live="polite" ${ref(syncScroll)}>
|
||||
${this.companion.exchanges.length === 0 && !this.companion.pendingQuestion
|
||||
? html`<p class="chat-session-rail__empty">${t("chat.rail.empty")}</p>`
|
||||
: nothing}
|
||||
${this.companion.exchanges.map((exchange) =>
|
||||
this.renderExchange(exchange.question, exchange.answer, exchange.ts),
|
||||
)}
|
||||
${this.companion.failedQuestion && this.companion.hint
|
||||
? html`
|
||||
<article class="chat-session-rail__exchange chat-session-rail__exchange--error">
|
||||
<div class="chat-session-rail__question">${this.companion.failedQuestion}</div>
|
||||
<div class="chat-session-rail__hint">
|
||||
${t(
|
||||
this.companion.hint === "busy"
|
||||
? "chat.rail.askBusy"
|
||||
: "chat.rail.askUnavailable",
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
: nothing}
|
||||
${this.companion.pendingQuestion
|
||||
? html`
|
||||
<article class="chat-session-rail__exchange chat-session-rail__exchange--pending">
|
||||
<div class="chat-session-rail__question">${this.companion.pendingQuestion}</div>
|
||||
<div class="chat-session-rail__hint">${t("chat.rail.askPending")}</div>
|
||||
</article>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const input = this.input();
|
||||
const mode = this.railState.mode(input);
|
||||
this.renderedMode = mode;
|
||||
if (mode === "hidden") {
|
||||
return nothing;
|
||||
}
|
||||
if (mode === "restore-icon") {
|
||||
return html`
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail chat-session-rail--restore"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.show")}
|
||||
title=${t("chat.rail.show")}
|
||||
@click=${() => this.show()}
|
||||
>
|
||||
${icons.activity}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
const digest = visibleDigest(input);
|
||||
if (mode === "pill") {
|
||||
return html`
|
||||
<div class="chat-session-rail chat-session-rail--pill" aria-live="polite">
|
||||
${digest ? this.renderStatus(digest) : nothing}
|
||||
<button
|
||||
class="chat-session-rail__expand"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.expand")}
|
||||
@click=${() => this.expand()}
|
||||
>
|
||||
<span class="chat-session-rail__headline"
|
||||
>${digest?.headline ?? t("chat.rail.title")}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__hide"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.hide")}
|
||||
@click=${() => this.hide()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__toggle"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.expand")}
|
||||
@click=${() => this.expand()}
|
||||
>
|
||||
${icons.chevronDown}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const elapsed =
|
||||
this.running && this.startedAt != null
|
||||
? formatDurationCompact(Math.max(0, this.now - this.startedAt))
|
||||
: null;
|
||||
const finished =
|
||||
digest && (digest.health === "done" || digest.health === "failed")
|
||||
? t("chat.rail.finished", {
|
||||
time: formatTimeAgo(Math.max(0, this.terminalAgeReference - digest.updatedAt)),
|
||||
})
|
||||
: null;
|
||||
return html`
|
||||
<section
|
||||
class="chat-session-rail chat-session-rail--expanded"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label=${t("chat.rail.title")}
|
||||
tabindex="-1"
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.collapse();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header class="chat-session-rail__header">
|
||||
<div class="chat-session-rail__header-copy">
|
||||
<div class="chat-session-rail__status-row">
|
||||
${digest ? this.renderStatus(digest) : html`<strong>${t("chat.rail.title")}</strong>`}
|
||||
${elapsed
|
||||
? html`<span class="chat-session-rail__timing">${elapsed}</span>`
|
||||
: finished
|
||||
? html`<span class="chat-session-rail__timing">${finished}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${digest
|
||||
? html`<strong class="chat-session-rail__headline">${digest.headline}</strong>`
|
||||
: html`<span class="chat-session-rail__subtitle">${t("chat.rail.subtitle")}</span>`}
|
||||
</div>
|
||||
<div class="chat-session-rail__actions">
|
||||
<openclaw-tooltip .content=${t("chat.rail.clear")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.clear")}
|
||||
?disabled=${!this.connected || this.companion.pendingQuestion !== null}
|
||||
@click=${() => this.onClear?.()}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__hide"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.hide")}
|
||||
@click=${() => this.hide()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__toggle"
|
||||
type="button"
|
||||
aria-label=${t("chat.rail.collapse")}
|
||||
@click=${() => this.collapse()}
|
||||
>
|
||||
${icons.chevronUp}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="chat-session-rail__digest">${this.renderDigestDetails(digest)}</div>
|
||||
${this.renderThread()}
|
||||
<form
|
||||
class="chat-session-rail__composer"
|
||||
@submit=${(event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
}}
|
||||
>
|
||||
<label class="chat-session-rail__prompt">
|
||||
<span class="sr-only">${t("chat.rail.askLabel")}</span>
|
||||
<input
|
||||
class="chat-session-rail__input"
|
||||
type="text"
|
||||
maxlength="400"
|
||||
autocomplete="off"
|
||||
.value=${this.companion.draft}
|
||||
placeholder=${this.companion.pendingQuestion
|
||||
? t("chat.rail.askPending")
|
||||
: t("chat.rail.askPlaceholder")}
|
||||
?disabled=${!this.connected || this.companion.pendingQuestion !== null}
|
||||
@input=${(event: InputEvent) => {
|
||||
this.onDraftChange?.((event.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__submit"
|
||||
type="submit"
|
||||
aria-label=${t("chat.rail.askSubmit")}
|
||||
?disabled=${!this.connected ||
|
||||
this.companion.pendingQuestion !== null ||
|
||||
!this.companion.draft.trim()}
|
||||
>
|
||||
${icons.cornerDownLeft}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-chat-session-rail")) {
|
||||
customElements.define("openclaw-chat-session-rail", ChatSessionRailElement);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// Floating side-chat panel: multi-turn /btw Q&A overlay pinned to the thread column.
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { buildSideChatFollowUpCommand } from "../../../lib/chat/side-question.ts";
|
||||
import type { ChatSideResult, ChatSideResultPending } from "../../../lib/chat/side-result.ts";
|
||||
import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
|
||||
type SideChatPanelProps = {
|
||||
turns: ChatSideResult[];
|
||||
pending: ChatSideResultPending | null;
|
||||
hidden: boolean;
|
||||
/** Archived/non-composable sessions render the transcript without the follow-up input. */
|
||||
canFollowUp: boolean;
|
||||
/** `question` is the user's typed follow-up for the pending-turn display;
|
||||
* `command` embeds prior-turn context and is never parsed back apart.
|
||||
* `onSendRejected` fires when the detached send is not accepted. */
|
||||
onFollowUp?: (command: string, question: string, onSendRejected?: () => void) => void;
|
||||
onClose?: () => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export function isSideChatPanelVisible(
|
||||
props: Pick<SideChatPanelProps, "turns" | "pending" | "hidden">,
|
||||
): boolean {
|
||||
return !props.hidden && (props.turns.length > 0 || props.pending != null);
|
||||
}
|
||||
|
||||
// Questions arrive display-ready: chat-send strips composer commands, the
|
||||
// server echo carries no /btw prefix, and panel follow-ups pass structured
|
||||
// text. Re-parsing here would corrupt questions that start with a command
|
||||
// token, so render them verbatim.
|
||||
function renderSideChatTurn(turn: ChatSideResult): TemplateResult {
|
||||
const question = turn.question;
|
||||
return html`
|
||||
<article class=${`chat-side-chat__turn ${turn.isError ? "chat-side-chat__turn--error" : ""}`}>
|
||||
<div class="chat-side-chat__question" dir=${detectTextDirection(question)}>${question}</div>
|
||||
<div class="chat-side-chat__answer" dir=${detectTextDirection(turn.text)}>
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(turn.text))}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSideChatPendingTurn(pending: ChatSideResultPending): TemplateResult {
|
||||
const question = pending.question;
|
||||
return html`
|
||||
<article class="chat-side-chat__turn chat-side-chat__turn--pending">
|
||||
<div class="chat-side-chat__question" dir=${detectTextDirection(question)}>${question}</div>
|
||||
<div class="chat-side-chat__thinking">${t("chat.sideChat.thinking")}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderSideChatPanel(props: SideChatPanelProps): TemplateResult | typeof nothing {
|
||||
if (!isSideChatPanelVisible(props)) {
|
||||
return nothing;
|
||||
}
|
||||
const { turns, pending } = props;
|
||||
// Error turns carry failure text, not an answer; the newest real turn is
|
||||
// the context a follow-up rides on.
|
||||
const lastTurn = turns.findLast((turn) => !turn.isError) ?? null;
|
||||
// New turns (or a new pending question) pin the scroll position to the
|
||||
// bottom; the key guard keeps unrelated re-renders from fighting the user's
|
||||
// manual scroll.
|
||||
const scrollKey = `${turns.length}:${pending?.runId ?? pending?.ts ?? ""}`;
|
||||
const syncScroll = (element: Element | undefined) => {
|
||||
if (!(element instanceof HTMLElement) || element.dataset.sideChatScrollKey === scrollKey) {
|
||||
return;
|
||||
}
|
||||
element.dataset.sideChatScrollKey = scrollKey;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
};
|
||||
const submitFollowUp = (input: HTMLInputElement) => {
|
||||
const followUp = buildSideChatFollowUpCommand(
|
||||
lastTurn ? { question: lastTurn.question, answer: lastTurn.text } : null,
|
||||
input.value,
|
||||
);
|
||||
if (!followUp || !props.onFollowUp) {
|
||||
return;
|
||||
}
|
||||
props.onFollowUp(followUp.command, followUp.question, () => {
|
||||
// A rejected detached send must not eat the typed follow-up; restore it
|
||||
// unless the user already typed something new.
|
||||
if (input.isConnected && !input.value) {
|
||||
input.value = followUp.question;
|
||||
}
|
||||
});
|
||||
input.value = "";
|
||||
};
|
||||
return html`
|
||||
<section class="chat-side-chat" role="dialog" aria-label=${t("chat.sideChat.title")}>
|
||||
<header class="chat-side-chat__header">
|
||||
<div class="chat-side-chat__heading">
|
||||
<h2 class="chat-side-chat__title">${t("chat.sideChat.title")}</h2>
|
||||
<span class="chat-side-chat__meta">${t("chat.sideChat.notSaved")}</span>
|
||||
</div>
|
||||
<div class="chat-side-chat__actions">
|
||||
<openclaw-tooltip .content=${t("chat.sideChat.clear")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.sideChat.clear")}
|
||||
@click=${() => props.onClear?.()}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<openclaw-tooltip .content=${t("chat.sideChat.close")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.sideChat.close")}
|
||||
@click=${() => props.onClose?.()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<div class="chat-side-chat__scroll" aria-live="polite" ${ref(syncScroll)}>
|
||||
${turns.map(renderSideChatTurn)} ${pending ? renderSideChatPendingTurn(pending) : nothing}
|
||||
</div>
|
||||
${props.canFollowUp
|
||||
? html`
|
||||
<footer class="chat-side-chat__composer">
|
||||
<!-- Disabled while a question is pending: a new /btw would retire
|
||||
the in-flight run and silently drop its answer. -->
|
||||
<div class="chat-side-chat__prompt">
|
||||
<input
|
||||
class="chat-side-chat__input"
|
||||
type="text"
|
||||
placeholder=${pending ? t("chat.sideChat.thinking") : t("chat.sideChat.followUp")}
|
||||
aria-label=${t("chat.sideChat.followUpLabel")}
|
||||
.disabled=${pending != null}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
submitFollowUp(event.currentTarget as HTMLInputElement);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-side-chat__send"
|
||||
type="button"
|
||||
aria-label=${t("chat.sideChat.sendFollowUp")}
|
||||
.disabled=${pending != null}
|
||||
@click=${(event: MouseEvent) => {
|
||||
const input = (event.currentTarget as HTMLElement)
|
||||
.closest(".chat-side-chat__prompt")
|
||||
?.querySelector<HTMLInputElement>(".chat-side-chat__input");
|
||||
if (input) {
|
||||
submitFollowUp(input);
|
||||
input.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.cornerDownLeft}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
`
|
||||
: nothing}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -31,11 +31,11 @@ import type {
|
||||
ChatStreamSegment,
|
||||
MessageGroup,
|
||||
} from "../../../lib/chat/chat-types.ts";
|
||||
import { extractTextCached } from "../../../lib/chat/message-extract.ts";
|
||||
import {
|
||||
buildMoreDetailsSideCommand,
|
||||
combineSideChatComposerDraft,
|
||||
} from "../../../lib/chat/side-question.ts";
|
||||
buildCompanionQuestionPrefill,
|
||||
buildMoreDetailsCompanionQuestion,
|
||||
} from "../../../lib/chat/companion-question.ts";
|
||||
import { extractTextCached } from "../../../lib/chat/message-extract.ts";
|
||||
import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts";
|
||||
import { copyToClipboard } from "../../../lib/clipboard.ts";
|
||||
import { fnv1aUtf16 } from "../../../lib/fnv1a.ts";
|
||||
@@ -155,15 +155,13 @@ type ChatThreadProps = {
|
||||
onChatScroll?: (event: Event) => void;
|
||||
onHistoryIntent?: (event: Event) => void;
|
||||
onDraftChange: (next: string) => void;
|
||||
/** Current composer draft; the selection popup preserves it when prefilling. */
|
||||
getDraft?: () => string;
|
||||
onSend: () => void;
|
||||
onSetReply?: (target: MessageReplyTarget) => void;
|
||||
onRewindMessage?: (entryId: string) => Promise<boolean> | boolean;
|
||||
onForkMessage?: (entryId: string) => Promise<void> | void;
|
||||
onFocusComposer?: () => void;
|
||||
/** Sends a detached /btw side question built from the selection popup. */
|
||||
onSideQuestion?: (command: string) => void;
|
||||
onCompanionQuestion?: (question: string) => void;
|
||||
onCompanionPrefill?: (question: string) => void;
|
||||
onOpenSession?: (sessionKey: string) => void;
|
||||
/** Tasks-rail snapshot backing the post-turn running-tasks status row. */
|
||||
backgroundTasks?: BackgroundTasksProps;
|
||||
@@ -629,10 +627,6 @@ export function renderChatSearchBar(
|
||||
`;
|
||||
}
|
||||
|
||||
export function isChatThreadSearchOpen(paneId: string): boolean {
|
||||
return getChatThreadState(paneId).searchOpen;
|
||||
}
|
||||
|
||||
export function toggleChatThreadSearch(paneId: string, requestUpdate: () => void): void {
|
||||
const state = getChatThreadState(paneId);
|
||||
state.searchOpen = !state.searchOpen;
|
||||
@@ -785,22 +779,23 @@ function createMessageActionContextButton(params: {
|
||||
}
|
||||
|
||||
function handleChatThreadSelectionPointerUp(event: PointerEvent, props: ChatThreadProps) {
|
||||
if (typeof props.onSideQuestion !== "function") {
|
||||
if (
|
||||
typeof props.onCompanionQuestion !== "function" ||
|
||||
typeof props.onCompanionPrefill !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleChatSelectionPointerUp(event, {
|
||||
onMoreDetails: (selection) => {
|
||||
const command = buildMoreDetailsSideCommand(selection);
|
||||
if (command) {
|
||||
props.onSideQuestion?.(command);
|
||||
const question = buildMoreDetailsCompanionQuestion(selection);
|
||||
if (question) {
|
||||
props.onCompanionQuestion?.(question);
|
||||
}
|
||||
},
|
||||
onAskSideChat: (selection) => {
|
||||
const draft = combineSideChatComposerDraft(selection, props.getDraft?.());
|
||||
if (draft) {
|
||||
props.onDraftChange(draft);
|
||||
props.onRequestUpdate?.();
|
||||
props.onFocusComposer?.();
|
||||
const question = buildCompanionQuestionPrefill(selection);
|
||||
if (question) {
|
||||
props.onCompanionPrefill?.(question);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,7 +59,6 @@ type RunLifecycleHost = Omit<
|
||||
chatStream?: string | null;
|
||||
chatStreamStartedAt?: number | null;
|
||||
chatRunStartup?: ChatRunStartupState | null;
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
compactionStatus?: CompactionStatus | null;
|
||||
compactionClearTimer?: TimerHandle | number | null;
|
||||
fallbackStatus?: FallbackStatus | null;
|
||||
@@ -84,7 +83,6 @@ type ReconcileOptions = {
|
||||
clearChatStream?: boolean;
|
||||
clearIndicators?: boolean;
|
||||
clearToolStream?: boolean;
|
||||
clearSideResultTerminalRuns?: boolean;
|
||||
clearRunStatus?: boolean;
|
||||
publishRunStatus?: boolean;
|
||||
armLocalTerminalReconcile?: boolean;
|
||||
@@ -436,9 +434,6 @@ export function reconcileChatRunLifecycle(host: RunLifecycleHost, options: Recon
|
||||
if (options.clearLocalRun) {
|
||||
host.chatRunId = null;
|
||||
}
|
||||
if (options.clearSideResultTerminalRuns) {
|
||||
host.chatSideResultTerminalRuns?.clear();
|
||||
}
|
||||
if (options.clearToolStream && canResetToolStream(host)) {
|
||||
resetToolStream(host);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
/* Positioning context for floating overlays (scroll-to-bottom, side chat). */
|
||||
/* Positioning context for floating overlays and the optional session rail. */
|
||||
position: relative;
|
||||
min-width: 312px;
|
||||
display: flex;
|
||||
@@ -172,6 +172,28 @@
|
||||
transition: flex 250ms ease-out;
|
||||
}
|
||||
|
||||
.chat-main__conversation {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-main--rail-docked {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.chat-main--rail-docked > openclaw-chat-session-rail {
|
||||
display: block;
|
||||
width: 400px;
|
||||
min-width: 400px;
|
||||
min-height: 0;
|
||||
flex: 0 0 400px;
|
||||
}
|
||||
|
||||
/* No border-left: the resizable divider rendered next to this panel draws
|
||||
the separator line; a border here doubles it. */
|
||||
.chat-sidebar {
|
||||
|
||||
+266
-325
@@ -1560,204 +1560,48 @@ openclaw-session-owner-chip {
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating side-chat panel: multi-turn /btw Q&A pinned to the thread column
|
||||
(position context: .chat-main). Top offset clears the floating toggle row
|
||||
that shares the thread's titlebar band. */
|
||||
.chat-side-chat {
|
||||
position: absolute;
|
||||
top: clamp(44px, 5vh, 52px);
|
||||
right: 12px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(400px, calc(100% - 24px));
|
||||
max-height: min(72%, 560px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fade-in 0.2s var(--ease-out);
|
||||
openclaw-chat-session-rail {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.chat-side-chat__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 10px 8px 16px;
|
||||
}
|
||||
|
||||
.chat-side-chat__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-side-chat__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-side-chat__meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-side-chat__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-side-chat__scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 2px 16px 14px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.chat-side-chat__turn + .chat-side-chat__turn {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-side-chat__question {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-side-chat__answer {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-side-chat__answer > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-side-chat__answer > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-side-chat__turn--error .chat-side-chat__answer {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-side-chat__thinking {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
animation: chat-side-chat-pending-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes chat-side-chat-pending-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-side-chat__composer {
|
||||
flex-shrink: 0;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.chat-side-chat__prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 4px 4px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.chat-side-chat__prompt:focus-within {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chat-side-chat__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-side-chat__input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-side-chat__input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-side-chat__send {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-side-chat__send:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Floating observer status: shares the side-chat anchor but stays narrower and
|
||||
yields to /btw by collapsing to its one-line pill. */
|
||||
.chat-observer-hud {
|
||||
.chat-session-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
z-index: 29;
|
||||
z-index: 30;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
background: color-mix(in srgb, var(--card) 96%, transparent);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fade-in 0.2s var(--ease-out);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.chat-observer-hud--pill {
|
||||
.chat-session-rail--pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: min(360px, calc(100% - 24px));
|
||||
min-height: 38px;
|
||||
padding: 4px 6px 4px 9px;
|
||||
gap: 5px;
|
||||
width: min(390px, calc(100% - 24px));
|
||||
min-height: 40px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
border-radius: 999px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-observer-hud--pill:hover {
|
||||
.chat-session-rail--pill:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.chat-observer-hud--card {
|
||||
width: min(380px, calc(100% - 24px));
|
||||
padding: 12px;
|
||||
.chat-session-rail--expanded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(400px, calc(100% - 24px));
|
||||
max-height: min(82%, 680px);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.chat-observer-hud--restore {
|
||||
.chat-session-rail--restore {
|
||||
width: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--muted);
|
||||
@@ -1765,90 +1609,134 @@ openclaw-session-owner-chip {
|
||||
transition: opacity 0.15s var(--ease-out);
|
||||
}
|
||||
|
||||
.chat-observer-hud--restore:hover,
|
||||
.chat-observer-hud--restore:focus-visible {
|
||||
.chat-session-rail--restore:hover,
|
||||
.chat-session-rail--restore:focus-visible {
|
||||
color: var(--text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-observer-hud__header,
|
||||
.chat-observer-hud__footer,
|
||||
.chat-observer-hud__plan-heading,
|
||||
.chat-observer-hud__prs,
|
||||
.chat-observer-hud__pr {
|
||||
.chat-session-rail__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 10px 10px 14px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.chat-session-rail__header-copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-session-rail__status-row,
|
||||
.chat-session-rail__status,
|
||||
.chat-session-rail__actions,
|
||||
.chat-session-rail__plan-heading,
|
||||
.chat-session-rail__prs,
|
||||
.chat-session-rail__pr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-observer-hud__header {
|
||||
gap: 4px;
|
||||
.chat-session-rail__status-row {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-observer-hud__headline {
|
||||
flex: 1;
|
||||
.chat-session-rail__status {
|
||||
--rail-health-color: var(--muted);
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
color: var(--rail-health-color);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-session-rail__status--critical {
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--rail-health-color) 14%, transparent);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-session-rail__status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--rail-health-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--rail-health-color) 13%, transparent);
|
||||
}
|
||||
|
||||
.chat-session-rail__status-icon {
|
||||
display: inline-flex;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-session-rail__status-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.chat-session-rail__status[data-health="on-track"],
|
||||
.chat-session-rail__status[data-health="done"],
|
||||
.chat-session-rail__status[data-health="wrapping-up"] {
|
||||
--rail-health-color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-session-rail__status[data-health="grinding"] {
|
||||
--rail-health-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-session-rail__status[data-health="stuck"],
|
||||
.chat-session-rail__status[data-health="waiting-on-user"] {
|
||||
--rail-health-color: var(--warn);
|
||||
}
|
||||
|
||||
.chat-session-rail__status[data-health="failed"] {
|
||||
--rail-health-color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-session-rail__timing {
|
||||
overflow: hidden;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-observer-hud__run-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted) 14%, transparent);
|
||||
}
|
||||
|
||||
.chat-observer-hud__status {
|
||||
--observer-health-color: var(--muted);
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--observer-health-color) 14%, transparent);
|
||||
color: var(--observer-health-color);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-observer-hud__status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--observer-health-color);
|
||||
}
|
||||
|
||||
.chat-observer-hud__status[data-health="on-track"],
|
||||
.chat-observer-hud__status[data-health="done"],
|
||||
.chat-observer-hud__status[data-health="wrapping-up"] {
|
||||
--observer-health-color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-observer-hud__status[data-health="grinding"] {
|
||||
--observer-health-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-observer-hud__status[data-health="stuck"],
|
||||
.chat-observer-hud__status[data-health="waiting-on-user"] {
|
||||
--observer-health-color: var(--warn);
|
||||
}
|
||||
|
||||
.chat-observer-hud__status[data-health="failed"] {
|
||||
--observer-health-color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-observer-hud__expand {
|
||||
flex: 1;
|
||||
.chat-session-rail__headline {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-session-rail__subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.chat-session-rail__expand {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
@@ -1859,26 +1747,36 @@ openclaw-session-owner-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-observer-hud__hide,
|
||||
.chat-observer-hud__toggle {
|
||||
flex: 0 0 auto;
|
||||
.chat-session-rail__hide,
|
||||
.chat-session-rail__toggle,
|
||||
.chat-session-rail__submit {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-observer-hud__assessment {
|
||||
margin: 9px 0 0;
|
||||
.chat-session-rail__actions {
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-session-rail__digest {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.chat-session-rail__assessment {
|
||||
margin: 11px 0 0;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-observer-hud__plan {
|
||||
.chat-session-rail__plan {
|
||||
margin-top: 11px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-observer-hud__plan-heading {
|
||||
.chat-session-rail__plan-heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
@@ -1888,7 +1786,7 @@ openclaw-session-owner-chip {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-observer-hud__plan-list {
|
||||
.chat-session-rail__plan-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 8px 0 0;
|
||||
@@ -1896,7 +1794,7 @@ openclaw-session-owner-chip {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chat-observer-hud__plan-item {
|
||||
.chat-session-rail__plan-item {
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
@@ -1904,22 +1802,22 @@ openclaw-session-owner-chip {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.chat-observer-hud__plan-item[data-status="completed"] {
|
||||
.chat-session-rail__plan-item[data-status="completed"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-observer-hud__plan-icon {
|
||||
.chat-session-rail__plan-icon {
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-observer-hud__prs {
|
||||
.chat-session-rail__prs {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.chat-observer-hud__pr {
|
||||
.chat-session-rail__pr {
|
||||
gap: 5px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -1930,106 +1828,149 @@ openclaw-session-owner-chip {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-observer-hud__pr:hover {
|
||||
.chat-session-rail__pr:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-observer-hud__pr-checks {
|
||||
.chat-session-rail__pr-checks,
|
||||
.chat-session-rail__timestamp,
|
||||
.chat-session-rail__hint,
|
||||
.chat-session-rail__empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-observer-hud__footer {
|
||||
.chat-session-rail__thread {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px 16px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.chat-session-rail__empty {
|
||||
margin: auto 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-session-rail__exchange {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 11px;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-session-rail__exchange + .chat-session-rail__exchange {
|
||||
padding-top: 13px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
}
|
||||
|
||||
.chat-session-rail__question {
|
||||
justify-self: end;
|
||||
max-width: 90%;
|
||||
padding: 6px 9px;
|
||||
border-radius: var(--radius-md) var(--radius-md) 3px var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-session-rail__answer {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-session-rail__answer > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-session-rail__answer > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-session-rail__timestamp {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.chat-observer-hud__run-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
box-shadow: none;
|
||||
.chat-session-rail__hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-observer-hud__run-dot[data-running] {
|
||||
background: var(--accent);
|
||||
animation: chat-side-chat-pending-pulse 1.4s ease-in-out infinite;
|
||||
.chat-session-rail__exchange--error .chat-session-rail__hint {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-thread {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 168px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
.chat-session-rail__exchange--pending .chat-session-rail__hint {
|
||||
animation: chat-session-rail-pending-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-exchange {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.4;
|
||||
@keyframes chat-session-rail-pending-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-question {
|
||||
justify-self: end;
|
||||
max-width: 88%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-answer {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-hint {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-form {
|
||||
.chat-session-rail__composer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 10px 11px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-field {
|
||||
flex: 1;
|
||||
.chat-session-rail__prompt {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-input {
|
||||
.chat-session-rail__input {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg);
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--secondary);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-input:focus {
|
||||
border-color: var(--accent);
|
||||
.chat-session-rail__input:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-observer-hud__ask-submit {
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
.chat-session-rail__input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-main--rail-docked .chat-session-rail--expanded {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-side-chat {
|
||||
.chat-session-rail {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: max(8px, var(--safe-area-left));
|
||||
@@ -2037,25 +1978,25 @@ openclaw-session-owner-chip {
|
||||
bottom: calc(108px + var(--safe-area-bottom));
|
||||
z-index: 35;
|
||||
width: auto;
|
||||
max-height: min(48vh, 380px);
|
||||
}
|
||||
|
||||
.chat-observer-hud {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: max(8px, var(--safe-area-left));
|
||||
right: max(8px, var(--safe-area-right));
|
||||
bottom: calc(108px + var(--safe-area-bottom));
|
||||
z-index: 34;
|
||||
width: auto;
|
||||
.chat-session-rail--expanded {
|
||||
max-height: min(58vh, 460px);
|
||||
}
|
||||
|
||||
.chat-observer-hud--restore {
|
||||
.chat-session-rail--restore {
|
||||
left: auto;
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-session-rail,
|
||||
.chat-session-rail__exchange--pending .chat-session-rail__hint {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Code Blocks
|
||||
=========================================== */
|
||||
|
||||
Reference in New Issue
Block a user