feat(ui): redesign session goal into interactive composer pill with elapsed time and /goal edit (#100736)

* feat(ui): redesign session goal into interactive composer pill with elapsed time and /goal edit

* docs: align goal command table cell padding

* docs: regenerate docs map for goal Control UI section
This commit is contained in:
Peter Steinberger 2026-07-06 09:42:49 +01:00 committed by GitHub
parent b80c276f13
commit 1b44efec8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 666 additions and 49 deletions

View file

@ -9218,6 +9218,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Token budgets
- H2: Model tools
- H2: Goal context on every turn
- H2: Control UI
- H2: TUI
- H2: Channel behavior
- H2: Troubleshooting

View file

@ -25,6 +25,7 @@ footer.
```text
/goal start get CI green for PR 87469 and push the fix
/goal
/goal edit get CI green for PR 87469, push the fix, and update docs
/goal pause waiting for CI
/goal resume
/goal complete pushed and verified
@ -64,7 +65,7 @@ Objective: get CI green for PR 87469 and push the fix
Tokens used: 12k
Token budget: 12k/50k
Commands: /goal pause, /goal complete, /goal clear
Commands: /goal edit <objective>, /goal pause, /goal complete, /goal clear
```
| Command | Effect |
@ -73,6 +74,7 @@ Commands: /goal pause, /goal complete, /goal clear
| `/goal start <objective>` | Create a new goal for the current session. |
| `/goal set <objective>`, `/goal create <objective>` | Aliases for `start`. |
| `/goal <objective>` | Also creates a new goal (any text that is not a recognized action word). |
| `/goal edit <objective>` | Reword the current objective; status and token accounting stay put. |
| `/goal pause [note]` | Pause an active goal. |
| `/goal resume [note]` | Resume a paused, blocked, usage-limited, or budget-limited goal. |
| `/goal complete [note]` | Mark the goal achieved. |
@ -155,6 +157,25 @@ OpenClaw keeps the line compact by truncating long objectives. Paused,
blocked, budget-limited, usage-limited, and complete goals are not injected,
so an operator stop remains in effect until the goal is resumed.
## Control UI
The web Control UI shows the goal as a compact pill above the chat composer:
a status icon, the status label (for example `Pursuing goal`), the truncated
objective, and a live elapsed timer.
The pill carries inline controls:
- **Pencil** prefills the composer with `/goal edit <objective>` so the
objective can be reworded and submitted.
- **Pause / resume** toggles between `/goal pause` and `/goal resume` based
on the current status.
- **Trash** sends `/goal clear`.
- **Chevron** expands the pill to show the full objective, the latest status
note, token usage, and elapsed time.
The action buttons are hidden while the composer cannot send (for example
when the gateway connection is down); the expand chevron keeps working.
## TUI
The TUI footer keeps the active session's goal visible next to the agent,

View file

@ -235,7 +235,7 @@ plugins.
| `/tools [compact\|verbose]` | Show what the current agent can use right now |
| `/status` | Show execution/runtime status, Gateway and system uptime, plugin health, plus provider usage/quota |
| `/status plugins` | Show detailed plugin health: load errors, quarantines, channel plugin failures, dependency issues, compatibility notices. Requires `commands.plugins: true` |
| `/goal [status\|start\|pause\|resume\|complete\|block\|clear] ...` | Manage the current session's durable [goal](/tools/goal) |
| `/goal [status\|start\|edit\|pause\|resume\|complete\|block\|clear] ...` | Manage the current session's durable [goal](/tools/goal) |
| `/diagnostics [note]` | Owner-only support-report flow. Asks for exec approval every time |
| `/crestodian <request>` | Run the Crestodian setup and repair helper from an owner DM |
| `/tasks` | List active/recent background tasks for the current session |

View file

@ -122,7 +122,7 @@ Session controls:
- `/trace <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
- `/goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear`
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`

View file

@ -258,9 +258,9 @@ export function buildBuiltinChatCommands(
args: [
{
name: "action",
description: "status, start, pause, resume, complete, block, clear",
description: "status, start, edit, pause, resume, complete, block, clear",
type: "string",
choices: ["status", "start", "pause", "resume", "complete", "block", "clear"],
choices: ["status", "start", "edit", "pause", "resume", "complete", "block", "clear"],
},
{
name: "text",

View file

@ -81,6 +81,10 @@ describe("goal commands", () => {
action: "pause",
text: "waiting on CI",
});
expect(parseGoalCommand("/goal edit ship the fix and docs")).toEqual({
action: "edit",
text: "ship the fix and docs",
});
});
it("formats command-looking continuation prompts so inline directives leave them intact", () => {
@ -221,6 +225,60 @@ describe("goal commands", () => {
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
});
it("edits the objective in place and replies without continuing", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "sess-main",
updatedAt: 1,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "finish the migration",
status: "active",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokenStartFresh: true,
tokensUsed: 0,
continuationTurns: 0,
},
},
});
const params = buildGoalParams("/goal edit finish the migration and update docs", storePath);
const result = await handleGoalCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("Goal updated: finish the migration and update docs");
const goal = getSessionEntry({ storePath, sessionKey })?.goal;
expect(goal?.objective).toBe("finish the migration and update docs");
expect(goal?.status).toBe("active");
expect(takeCommandSessionMetadataChanges(params.ctx)).toEqual([
{ sessionKey, reason: "command-metadata" },
]);
});
it("rejects goal edit without a goal or new objective", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: { sessionId: "sess-main", updatedAt: 1 },
});
const usage = await handleGoalCommand(buildGoalParams("/goal edit", storePath), true);
expect(usage?.reply?.text).toBe("Usage: /goal edit <objective>");
const missing = await handleGoalCommand(
buildGoalParams("/goal edit new target", storePath),
true,
);
expect(missing?.reply?.text).toBe("Goal error: goal not found");
});
it("renders status without persisting derived budget state", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({

View file

@ -9,6 +9,7 @@ import {
formatSessionGoalStatus,
getSessionEntry,
getSessionGoal,
updateSessionGoalObjective,
updateSessionGoalStatus,
} from "../../config/sessions.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
@ -31,6 +32,7 @@ const GOAL_ACTIONS = new Set([
"complete",
"create",
"done",
"edit",
"pause",
"resume",
"set",
@ -195,6 +197,20 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
applyGoalContinuationPrompt(params, formatGoalContinuationPrompt(goal.objective));
return goalContinuation();
}
case "edit": {
const objective = normalizeOptionalString(parsed.text);
if (!objective) {
return goalReply("Usage: /goal edit <objective>");
}
const goal = await updateSessionGoalObjective({
sessionKey: params.sessionKey,
storePath: params.storePath,
objective,
});
syncGoalSessionEntry(params);
markCommandSessionMetadataChanged(params);
return goalReply(`Goal updated: ${goal.objective}`);
}
case "pause": {
const goal = await updateSessionGoalStatus({
sessionKey: params.sessionKey,
@ -256,7 +272,7 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
}
default:
return goalReply(
"Usage: /goal <objective> | /goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear",
"Usage: /goal <objective> | /goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear",
);
}
} catch (error) {

View file

@ -6,6 +6,7 @@ import {
formatSessionGoalStatus,
getSessionGoal,
resolveSessionGoalDisplayState,
updateSessionGoalObjective,
updateSessionGoalStatus,
} from "./goals.js";
import { getSessionEntry, upsertSessionEntry } from "./store.js";
@ -345,7 +346,68 @@ describe("session goals", () => {
expect(text).toContain("Goal\nStatus: blocked\nObjective: land the PR");
expect(text).toContain("Token budget: 12k/30k");
expect(text).toContain("Commands: /goal resume, /goal clear");
expect(text).toContain("Commands: /goal resume, /goal edit <objective>, /goal clear");
});
it("rewords the objective without touching status or token accounting", async () => {
await writeSession(100);
await createSessionGoal({
storePath: fixture.storePath(),
sessionKey,
objective: "ship the fix",
tokenBudget: 50,
now: 10,
});
const updated = await updateSessionGoalObjective({
storePath: fixture.storePath(),
sessionKey,
objective: "ship the fix and update docs",
now: 20,
});
expect(updated.objective).toBe("ship the fix and update docs");
expect(updated.status).toBe("active");
expect(updated.tokenStart).toBe(100);
expect(updated.tokenBudget).toBe(50);
expect(updated.updatedAt).toBe(20);
expect(getSessionEntry({ storePath: fixture.storePath(), sessionKey })?.goal?.objective).toBe(
"ship the fix and update docs",
);
});
it("rejects rewording terminal or missing goals", async () => {
await writeSession(0);
await expect(
updateSessionGoalObjective({
storePath: fixture.storePath(),
sessionKey,
objective: "anything",
now: 10,
}),
).rejects.toThrow(/goal not found/);
await createSessionGoal({
storePath: fixture.storePath(),
sessionKey,
objective: "ship",
now: 10,
});
await updateSessionGoalStatus({
storePath: fixture.storePath(),
sessionKey,
status: "complete",
now: 20,
});
await expect(
updateSessionGoalObjective({
storePath: fixture.storePath(),
sessionKey,
objective: "new target",
now: 30,
}),
).rejects.toThrow(/already complete/);
});
it("projects display state from fresh session tokens", () => {

View file

@ -143,12 +143,12 @@ export function formatSessionGoalStatus(goal: SessionGoal | undefined): string {
function resolveGoalCommandHint(status: SessionGoalStatus): string {
switch (status) {
case "active":
return "/goal pause, /goal complete, /goal clear";
return "/goal edit <objective>, /goal pause, /goal complete, /goal clear";
case "paused":
case "blocked":
case "usage_limited":
case "budget_limited":
return "/goal resume, /goal clear";
return "/goal resume, /goal edit <objective>, /goal clear";
case "complete":
return "/goal clear";
}
@ -287,6 +287,39 @@ export async function updateSessionGoalStatus(
return cloneGoal(updated);
}
export async function updateSessionGoalObjective(
options: SessionGoalStoreOptions & { objective: string },
): Promise<SessionGoal> {
const objective = options.objective.trim();
if (!objective) {
throw new Error("objective required");
}
const now = nowMs(options.now);
let updated: SessionGoal | undefined;
let foundSession = false;
const result = await patchSessionEntry({
sessionKey: options.sessionKey,
storePath: options.storePath,
update: (entry) => {
foundSession = true;
const accounted = accountGoalUsage(entry, now);
if (!accounted) {
throw new Error("goal not found");
}
if (TERMINAL_GOAL_STATUSES.has(accounted.status)) {
throw new Error(`goal is already ${accounted.status}`);
}
// Rewording keeps status and token accounting; only the target moves.
updated = { ...accounted, objective, updatedAt: now };
return { goal: updated };
},
});
if (!result || !updated) {
throw new Error(foundSession ? "goal not found" : "session not found");
}
return cloneGoal(updated);
}
export async function clearSessionGoal(options: SessionGoalStoreOptions): Promise<boolean> {
let removed = false;
const result = await patchSessionEntry({

View file

@ -25,6 +25,7 @@ import {
createSessionGoal,
formatSessionGoalStatus,
getSessionGoal,
updateSessionGoalObjective,
updateSessionGoalStatus,
} from "../config/sessions.js";
import { applySessionPatchProjection } from "../config/sessions/session-accessor.js";
@ -812,6 +813,14 @@ export class EmbeddedTuiBackend implements TuiBackend {
});
return { text: `Goal started: ${goal.objective}` };
}
case "edit": {
const objective = parsed.text.trim();
if (!objective) {
return { text: "Usage: /goal edit <objective>" };
}
const goal = await updateSessionGoalObjective({ sessionKey, storePath, objective });
return { text: `Goal updated: ${goal.objective}` };
}
case "pause": {
const goal = await updateSessionGoalStatus({
sessionKey,
@ -856,7 +865,7 @@ export class EmbeddedTuiBackend implements TuiBackend {
}
default:
return {
text: "Usage: /goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear",
text: "Usage: /goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear",
};
}
}

View file

@ -135,6 +135,19 @@ export const icons = {
`,
check: html` <svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg> `,
play: html` <svg viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3" /></svg> `,
pause: html`
<svg viewBox="0 0 24 24">
<rect x="14" y="4" width="4" height="16" rx="1" />
<rect x="6" y="4" width="4" height="16" rx="1" />
</svg>
`,
target: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
`,
archive: html`
<svg viewBox="0 0 24 24">
<rect width="20" height="5" x="2" y="3" rx="1" />

View file

@ -1,7 +1,13 @@
// Control UI tests cover session goal behavior.
import { describe, expect, it } from "vitest";
import type { SessionGoal } from "../api/types.ts";
import { formatGoalDetail, formatGoalSummary, formatGoalTokenCount } from "./session-goal.ts";
import {
formatGoalDetail,
formatGoalElapsed,
formatGoalSummary,
formatGoalTokenCount,
goalElapsedMs,
} from "./session-goal.ts";
function buildGoal(overrides: Partial<SessionGoal> = {}): SessionGoal {
return {
@ -42,4 +48,32 @@ describe("session goal formatting", () => {
"Goal achieved (12k used)",
);
});
it("tracks elapsed time live for active goals and freezes it on status stops", () => {
const active = buildGoal({ createdAt: 1_000 });
expect(goalElapsedMs(active, 16_000)).toBe(15_000);
const paused = buildGoal({ status: "paused", createdAt: 1_000, pausedAt: 61_000 });
expect(goalElapsedMs(paused, 999_000)).toBe(60_000);
const complete = buildGoal({ status: "complete", createdAt: 1_000, completedAt: 121_000 });
expect(goalElapsedMs(complete, 999_000)).toBe(120_000);
const blockedWithoutTimestamp = buildGoal({
status: "blocked",
createdAt: 1_000,
updatedAt: 31_000,
});
expect(goalElapsedMs(blockedWithoutTimestamp, 999_000)).toBe(30_000);
});
it("formats elapsed durations compactly", () => {
expect(formatGoalElapsed(0)).toBe("0s");
expect(formatGoalElapsed(15_000)).toBe("15s");
expect(formatGoalElapsed(59_999)).toBe("59s");
expect(formatGoalElapsed(60_000)).toBe("1m");
expect(formatGoalElapsed(3_540_000)).toBe("59m");
expect(formatGoalElapsed(3_600_000)).toBe("1h");
expect(formatGoalElapsed(3_900_000)).toBe("1h 5m");
});
});

View file

@ -55,6 +55,43 @@ export function formatGoalSummary(goal: SessionGoal): string {
return usage ? `${status} (${usage})` : status;
}
/** Wall-clock time spent on the goal; frozen at the status timestamp once not active. */
export function goalElapsedMs(goal: SessionGoal, now: number): number {
const stoppedAt = (() => {
switch (goal.status) {
case "active":
return now;
case "paused":
return goal.pausedAt ?? goal.updatedAt;
case "blocked":
return goal.blockedAt ?? goal.updatedAt;
case "usage_limited":
return goal.usageLimitedAt ?? goal.updatedAt;
case "budget_limited":
return goal.budgetLimitedAt ?? goal.updatedAt;
case "complete":
return goal.completedAt ?? goal.updatedAt;
}
const unreachable: never = goal.status;
return unreachable;
})();
return Math.max(0, stoppedAt - goal.createdAt);
}
export function formatGoalElapsed(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes}m`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
export function formatGoalDetail(goal: SessionGoal): string {
const note = goal.lastStatusNote ? ` - ${goal.lastStatusNote}` : "";
return `${formatGoalSummary(goal)}: ${goal.objective}${note}`;

View file

@ -764,6 +764,7 @@ export class ChatPage extends LitElement {
onQueueRemove: state.removeQueuedMessage,
onQueueRetry: (id) => void state.retryQueuedChatMessage(id),
onQueueSteer: (id) => void state.steerQueuedChatMessage(id),
onGoalCommand: (command) => void state.handleSendChat(command),
onDismissSideResult: () => {
state.chatSideResult = null;
state.requestUpdate?.();

View file

@ -960,36 +960,122 @@ describe("chat history render window", () => {
});
describe("chat goal status", () => {
it("renders the active session goal inside the composer", () => {
const container = renderChatView({
sessions: createSessionsResultFromRows([
{
key: "main",
kind: "direct",
function goalSessions(goal: Partial<NonNullable<GatewaySessionRow["goal"]>> = {}) {
return createSessionsResultFromRows([
{
key: "main",
kind: "direct",
updatedAt: 2,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "Land the web goal UI",
status: "active",
createdAt: Date.now() - 15_000,
updatedAt: 2,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "Land the web goal UI",
status: "active",
createdAt: 1,
updatedAt: 2,
tokenStart: 100,
tokensUsed: 12_400,
tokenBudget: 50_000,
continuationTurns: 0,
},
tokenStart: 100,
tokensUsed: 12_400,
tokenBudget: 50_000,
continuationTurns: 0,
...goal,
},
]),
});
},
]);
}
it("renders the goal pill with status, objective, and elapsed time", () => {
const container = renderChatView({ sessions: goalSessions() });
const goal = container.querySelector(".agent-chat__goal");
expect(goal?.textContent?.replace(/\s+/g, " ").trim()).toBe(
"Pursuing goal (12k/50k) Land the web goal UI",
expect(goal?.querySelector(".agent-chat__goal-label")?.textContent).toBe("Pursuing goal");
expect(goal?.querySelector(".agent-chat__goal-objective")?.textContent).toBe(
"Land the web goal UI",
);
expect(goal?.querySelector(".agent-chat__goal-elapsed")?.textContent).toBe("15s");
expect(goal?.getAttribute("aria-label")).toBe("Pursuing goal (12k/50k): Land the web goal UI");
expect(goal?.closest(".agent-chat__composer-status-stack")).not.toBeNull();
});
it("dispatches goal commands from the pill controls", () => {
const onGoalCommand = vi.fn();
const container = renderChatView({ sessions: goalSessions(), onGoalCommand });
container.querySelector<HTMLButtonElement>('button[aria-label="Pause goal"]')?.click();
container.querySelector<HTMLButtonElement>('button[aria-label="Clear goal"]')?.click();
expect(onGoalCommand).toHaveBeenNthCalledWith(1, "/goal pause");
expect(onGoalCommand).toHaveBeenNthCalledWith(2, "/goal clear");
expect(container.querySelector('button[aria-label="Resume goal"]')).toBeNull();
});
it("offers resume instead of pause for paused goals", () => {
const onGoalCommand = vi.fn();
const container = renderChatView({
sessions: goalSessions({ status: "paused", pausedAt: Date.now() }),
onGoalCommand,
});
expect(container.querySelector('button[aria-label="Pause goal"]')).toBeNull();
container.querySelector<HTMLButtonElement>('button[aria-label="Resume goal"]')?.click();
expect(onGoalCommand).toHaveBeenCalledWith("/goal resume");
});
it("prefills the composer draft when editing the goal", () => {
const onDraftChange = vi.fn();
const container = renderChatView({
sessions: goalSessions(),
onGoalCommand: vi.fn(),
onDraftChange,
});
container.querySelector<HTMLButtonElement>('button[aria-label="Edit goal"]')?.click();
expect(onDraftChange).toHaveBeenCalledWith("/goal edit Land the web goal UI");
});
it("expands goal details on demand", () => {
const props = createChatProps({
sessions: goalSessions({ lastStatusNote: "Waiting for CI" }),
onGoalCommand: vi.fn(),
});
const container = document.createElement("div");
render(renderChat(props), container);
expect(container.querySelector(".agent-chat__goal-detail")).toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Show goal details"]',
);
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
toggle?.click();
render(renderChat(props), container);
const detail = container.querySelector(".agent-chat__goal-detail");
expect(detail?.querySelector(".agent-chat__goal-detail-objective")?.textContent).toBe(
"Land the web goal UI",
);
expect(detail?.querySelector(".agent-chat__goal-detail-note")?.textContent).toBe(
"Waiting for CI",
);
expect(detail?.querySelector(".agent-chat__goal-detail-meta")?.textContent?.trim()).toBe(
"12k/50k · 15s",
);
expect(
container
.querySelector('button[aria-label="Hide goal details"]')
?.getAttribute("aria-expanded"),
).toBe("true");
});
it("hides goal action buttons when the composer cannot send", () => {
const container = renderChatView({
sessions: goalSessions(),
onGoalCommand: vi.fn(),
connected: false,
});
expect(container.querySelector('button[aria-label="Pause goal"]')).toBeNull();
expect(container.querySelector('button[aria-label="Show goal details"]')).not.toBeNull();
});
});
describe("chat composer workbench", () => {

View file

@ -123,6 +123,7 @@ export type ChatProps = {
onQueueRemove: (id: string) => void;
onQueueRetry?: (id: string) => void;
onQueueSteer?: (id: string) => void;
onGoalCommand?: (command: string) => void;
onDismissSideResult?: () => void;
onNewSession: () => void;
onClearHistory?: () => void;
@ -246,6 +247,7 @@ export function renderChat(props: ChatProps) {
onQueueRemove: props.onQueueRemove,
onQueueRetry: props.onQueueRetry,
onQueueSteer: props.onQueueSteer,
onGoalCommand: props.onGoalCommand,
onDismissSideResult: props.onDismissSideResult,
onNewSession: props.onNewSession,
onClearReply: props.onClearReply,

View file

@ -19,7 +19,13 @@ import {
} from "../../../lib/chat/commands.ts";
import type { ChatSideResult } from "../../../lib/chat/side-result.ts";
import { formatCompactTokenCount, formatCost } from "../../../lib/format.ts";
import { formatGoalDetail, formatGoalSummary } from "../../../lib/session-goal.ts";
import {
formatGoalDetail,
formatGoalElapsed,
formatGoalStatusLabel,
formatGoalUsage,
goalElapsedMs,
} from "../../../lib/session-goal.ts";
import { detectTextDirection } from "../../../lib/text-direction.ts";
import {
getChatAttachmentPreviewUrl,
@ -107,6 +113,7 @@ export type ChatComposerProps = {
onClearReply?: () => void;
onScrollToBottom?: () => void;
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
onGoalCommand?: (command: string) => void;
};
type PendingClearedSubmittedDraft = {
@ -126,6 +133,7 @@ type ChatComposerState = {
composerComposing: boolean;
composerInputIntentKey: string | null;
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
goalExpandedId: string | null;
};
function createChatComposerState(): ChatComposerState {
@ -141,6 +149,7 @@ function createChatComposerState(): ChatComposerState {
composerComposing: false,
composerInputIntentKey: null,
pendingClearedSubmittedDraft: null,
goalExpandedId: null,
};
}
@ -217,6 +226,10 @@ function suppressStaleSubmittedDraftReplay(
export function resetChatComposerState() {
Object.assign(composerState, createChatComposerState());
for (const timer of goalElapsedTimers.values()) {
clearInterval(timer);
}
goalElapsedTimers.clear();
}
const composerTextareaResizeObservers = new WeakMap<HTMLTextAreaElement, ResizeObserver>();
@ -287,21 +300,157 @@ function restoreHistoryCaret(target: HTMLTextAreaElement, direction: "up" | "dow
});
}
function renderChatGoal(goal: SessionGoal | undefined): TemplateResult | typeof nothing {
const goalElapsedTimers = new Map<HTMLElement, ReturnType<typeof setInterval>>();
function clearGoalElapsedTimer(el: HTMLElement) {
const timer = goalElapsedTimers.get(el);
if (timer !== undefined) {
clearInterval(timer);
goalElapsedTimers.delete(el);
}
}
// Ticks the elapsed span in place so an idle active goal does not force
// full chat re-renders every second.
function createGoalElapsedRef(goal: SessionGoal) {
let bound: HTMLElement | null = null;
return (element: Element | undefined) => {
if (bound) {
clearGoalElapsedTimer(bound);
bound = null;
}
if (!(element instanceof HTMLElement) || goal.status !== "active") {
return;
}
bound = element;
const timer = setInterval(() => {
// Tests and detached renders can drop the pill without a final ref call.
if (!element.isConnected) {
clearGoalElapsedTimer(element);
return;
}
element.textContent = formatGoalElapsed(goalElapsedMs(goal, Date.now()));
}, 1000);
goalElapsedTimers.set(element, timer);
};
}
type ChatGoalActions = {
canAct: boolean;
onGoalCommand?: (command: string) => void;
onGoalEdit?: (goal: SessionGoal) => void;
requestUpdate: () => void;
};
function renderChatGoalActionButton(options: {
className: string;
label: string;
icon: TemplateResult;
onClick: () => void;
}): TemplateResult {
return html`
<openclaw-tooltip content=${options.label}>
<button
class="agent-chat__goal-action ${options.className}"
type="button"
aria-label=${options.label}
@click=${options.onClick}
>
${options.icon}
</button>
</openclaw-tooltip>
`;
}
function renderChatGoal(
goal: SessionGoal | undefined,
actions: ChatGoalActions,
): TemplateResult | typeof nothing {
if (!goal) {
return nothing;
}
const elapsed = formatGoalElapsed(goalElapsedMs(goal, Date.now()));
const usage = formatGoalUsage(goal);
const expanded = composerState.goalExpandedId === goal.id;
const showActions = actions.canAct && Boolean(actions.onGoalCommand);
const canResume =
goal.status === "paused" ||
goal.status === "blocked" ||
goal.status === "usage_limited" ||
goal.status === "budget_limited";
const toggleExpanded = () => {
composerState.goalExpandedId = expanded ? null : goal.id;
actions.requestUpdate();
};
return html`
<openclaw-tooltip .content=${formatGoalDetail(goal)}>
<div
class="agent-chat__goal agent-chat__goal--${goal.status}"
role="status"
aria-label=${formatGoalDetail(goal)}
>
<span class="agent-chat__goal-label">${formatGoalSummary(goal)}</span>
<div
class="agent-chat__goal agent-chat__goal--${goal.status}"
role="group"
aria-label=${formatGoalDetail(goal)}
>
<div class="agent-chat__goal-row">
<span class="agent-chat__goal-icon">${icons.target}</span>
<span class="agent-chat__goal-label">${formatGoalStatusLabel(goal.status)}</span>
<span class="agent-chat__goal-objective">${goal.objective}</span>
<span class="agent-chat__goal-elapsed" ${ref(createGoalElapsedRef(goal))}>${elapsed}</span>
<span class="agent-chat__goal-actions">
${showActions && actions.onGoalEdit && goal.status !== "complete"
? renderChatGoalActionButton({
className: "agent-chat__goal-edit",
label: "Edit goal",
icon: icons.penLine,
onClick: () => actions.onGoalEdit?.(goal),
})
: nothing}
${showActions && goal.status === "active"
? renderChatGoalActionButton({
className: "agent-chat__goal-pause",
label: "Pause goal",
icon: icons.pause,
onClick: () => actions.onGoalCommand?.("/goal pause"),
})
: nothing}
${showActions && canResume
? renderChatGoalActionButton({
className: "agent-chat__goal-resume",
label: "Resume goal",
icon: icons.play,
onClick: () => actions.onGoalCommand?.("/goal resume"),
})
: nothing}
${showActions
? renderChatGoalActionButton({
className: "agent-chat__goal-clear",
label: "Clear goal",
icon: icons.trash,
onClick: () => actions.onGoalCommand?.("/goal clear"),
})
: nothing}
<button
class="agent-chat__goal-action agent-chat__goal-expand"
type="button"
aria-expanded=${expanded ? "true" : "false"}
aria-label=${expanded ? "Hide goal details" : "Show goal details"}
@click=${toggleExpanded}
>
${expanded ? icons.chevronDown : icons.chevronRight}
</button>
</span>
</div>
</openclaw-tooltip>
${expanded
? html`
<div class="agent-chat__goal-detail">
<div class="agent-chat__goal-detail-objective">${goal.objective}</div>
${goal.lastStatusNote
? html`<div class="agent-chat__goal-detail-note">${goal.lastStatusNote}</div>`
: nothing}
<div class="agent-chat__goal-detail-meta">
${usage ? `${usage} · ${elapsed}` : elapsed}
</div>
</div>
`
: nothing}
</div>
`;
}
@ -1891,7 +2040,17 @@ export function renderChatComposer(props: ChatComposerProps) {
: nothing}
<div class="agent-chat__composer-status-stack">
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)} ${renderChatGoal(activeSession?.goal)}
${renderCompactionIndicator(props.compactionStatus)}
${renderChatGoal(activeSession?.goal, {
canAct: canCompose,
onGoalCommand: props.onGoalCommand,
onGoalEdit: (goal) => {
commitComposerDraft(props, `/goal edit ${goal.objective}`);
requestUpdate();
queueMicrotask(() => composerTextarea?.focus({ preventScroll: true }));
},
requestUpdate,
})}
</div>
<input

View file

@ -360,19 +360,41 @@ openclaw-chat-page {
.agent-chat__goal {
align-self: center;
display: flex;
align-items: center;
flex-direction: column;
max-width: calc(100% - 20px);
gap: 8px;
min-width: 0;
margin: 0 auto 8px;
padding: 7px 12px;
padding: 5px 8px 5px 12px;
color: color-mix(in srgb, var(--text) 82%, var(--muted));
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-sm);
border-radius: var(--radius-lg);
font-size: 12px;
line-height: 1.25;
}
.agent-chat__goal-row {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.agent-chat__goal-icon {
display: inline-flex;
flex-shrink: 0;
}
.agent-chat__goal-icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.agent-chat__goal-label,
.agent-chat__goal-objective {
min-width: 0;
@ -382,7 +404,7 @@ openclaw-chat-page {
}
.agent-chat__goal-label {
flex: 0 1 auto;
flex: 0 0 auto;
font-weight: 700;
}
@ -391,6 +413,69 @@ openclaw-chat-page {
color: var(--muted);
}
.agent-chat__goal-elapsed {
flex-shrink: 0;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.agent-chat__goal-actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 2px;
}
.agent-chat__goal-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
color: var(--muted);
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.agent-chat__goal-action:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.agent-chat__goal-action svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.agent-chat__goal-detail {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
padding: 6px 2px 3px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
color: var(--text);
}
.agent-chat__goal-detail-objective {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.agent-chat__goal-detail-note,
.agent-chat__goal-detail-meta {
color: var(--muted);
overflow-wrap: anywhere;
}
.agent-chat__goal--active {
color: var(--success);
background: color-mix(in srgb, var(--success) 9%, var(--card));