mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
b80c276f13
commit
1b44efec8c
18 changed files with 666 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue