feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded in-progress snapshot piggybacked on lease renewal. Cloud persists that snapshot per active assignment and exposes it through the existing task list/detail query path; Cloud Console renders it as a live badge. Host Agent's local console gains authenticated, read-only task list and detail/timeline pages (same-origin, server-rendered) with inlined screenshots. Also fixes a pre-existing gap in the shared Timeline: the actual per-step LLM prompt is now recorded instead of the task goal, benefiting both Runtime and Host Agent consoles. When a host uses the cloud planner transport, each decide call's prompt and resulting tool decision are durably logged in a new planner_decision_log table (with bounded retention) and browsable from Cloud Console; direct-transport hosts explicitly surface a "not reported" state. Includes Alembic migrations 0008 (progress columns on scheduled_tasks) and 0009 (planner_decision_log), bounded Host-Agent-local retention, dual-backend repository parity, and Vitest + pytest coverage. Task 6.5 (manual end-to-end device verification) remains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ import type {
|
||||
LlmProviderSettings,
|
||||
LlmProviderType,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
PlannerDecisionListResponse,
|
||||
PluginRecord,
|
||||
PluginRegistrationPayload,
|
||||
TaskAttempt,
|
||||
@@ -232,6 +234,15 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||
}
|
||||
|
||||
export function getTaskPlannerDecisions(
|
||||
taskId: string,
|
||||
attempt: number,
|
||||
): Promise<PlannerDecisionItem[]> {
|
||||
return request<PlannerDecisionListResponse>(
|
||||
`/v1/tasks/${encodeURIComponent(taskId)}/planner-decisions?attempt=${attempt}`,
|
||||
).then((resp) => resp.items);
|
||||
}
|
||||
|
||||
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
|
||||
return request<{ task_id: string }>("/v1/tasks", {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computePlannerHistoryState } from "./plannerHistory";
|
||||
import type { PlannerDecisionItem } from "./types";
|
||||
|
||||
function makeDecision(
|
||||
overrides: Partial<PlannerDecisionItem> = {},
|
||||
): PlannerDecisionItem {
|
||||
return {
|
||||
step_index: 0,
|
||||
attempt: 0,
|
||||
system_prompt: "system",
|
||||
user_prompt: "user",
|
||||
tool_name: "tap",
|
||||
arguments: { target: "button" },
|
||||
created_at: "2026-07-14T00:00:00+00:00",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computePlannerHistoryState", () => {
|
||||
it("returns populated when decisions exist (cloud transport)", () => {
|
||||
const decisions = [
|
||||
makeDecision({ step_index: 0, tool_name: "tap" }),
|
||||
makeDecision({ step_index: 1, tool_name: "swipe" }),
|
||||
];
|
||||
const state = computePlannerHistoryState(decisions, "cloud");
|
||||
expect(state).toEqual({ kind: "populated", decisions });
|
||||
});
|
||||
|
||||
it("returns populated when decisions exist even if transport is direct", () => {
|
||||
const decisions = [makeDecision()];
|
||||
const state = computePlannerHistoryState(decisions, "direct");
|
||||
expect(state).toEqual({ kind: "populated", decisions });
|
||||
});
|
||||
|
||||
it("returns empty_cloud_transport when no decisions but host is cloud-transport", () => {
|
||||
const state = computePlannerHistoryState([], "cloud");
|
||||
expect(state).toEqual({ kind: "empty_cloud_transport" });
|
||||
});
|
||||
|
||||
it("returns direct_transport_hidden when no decisions and host is direct-transport", () => {
|
||||
const state = computePlannerHistoryState([], "direct");
|
||||
expect(state).toEqual({ kind: "direct_transport_hidden" });
|
||||
});
|
||||
|
||||
it("returns unknown_host_transport when host transport is null", () => {
|
||||
const state = computePlannerHistoryState([], null);
|
||||
expect(state).toEqual({ kind: "unknown_host_transport" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PlannerDecisionItem } from "./types";
|
||||
|
||||
export type PlannerHistoryState =
|
||||
| { kind: "populated"; decisions: PlannerDecisionItem[] }
|
||||
| { kind: "empty_cloud_transport" }
|
||||
| { kind: "direct_transport_hidden" }
|
||||
| { kind: "unknown_host_transport" };
|
||||
|
||||
/**
|
||||
* Decide which empty-state or populated-state message to show for a task's
|
||||
* planner decision history, based on whether any decisions were returned and
|
||||
* the assigned host's configured planner transport.
|
||||
*
|
||||
* - **populated**: decisions exist — render them.
|
||||
* - **direct_transport_hidden**: host uses the `direct` transport, which never
|
||||
* sends prompts to Cloud. Show an explicit "not reported" message instead of
|
||||
* an empty list.
|
||||
* - **empty_cloud_transport**: host IS cloud-transport but no decisions have
|
||||
* been persisted yet (task just started or no steps executed).
|
||||
* - **unknown_host_transport**: host not found or transport field not reported.
|
||||
*/
|
||||
export function computePlannerHistoryState(
|
||||
decisions: PlannerDecisionItem[],
|
||||
hostTransport: "direct" | "cloud" | null,
|
||||
): PlannerHistoryState {
|
||||
if (decisions.length > 0) {
|
||||
return { kind: "populated", decisions };
|
||||
}
|
||||
if (hostTransport === "direct") {
|
||||
return { kind: "direct_transport_hidden" };
|
||||
}
|
||||
if (hostTransport === "cloud") {
|
||||
return { kind: "empty_cloud_transport" };
|
||||
}
|
||||
return { kind: "unknown_host_transport" };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatTaskProgress } from "./taskProgress";
|
||||
import type { TaskListItem } from "./types";
|
||||
|
||||
function makeTask(
|
||||
overrides: Partial<TaskListItem> & Pick<TaskListItem, "status">,
|
||||
): TaskListItem {
|
||||
return {
|
||||
id: "task-a",
|
||||
goal: null,
|
||||
workflow_definition_id: null,
|
||||
assigned_device_id: null,
|
||||
assigned_host_id: null,
|
||||
attempt_count: 0,
|
||||
failure_reason: null,
|
||||
target_host_id: null,
|
||||
target_device_id: null,
|
||||
created_at: "2026-07-14T00:00:00+00:00",
|
||||
progress_step_index: null,
|
||||
progress_step_status: null,
|
||||
progress_summary: null,
|
||||
progress_updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatTaskProgress", () => {
|
||||
it("renders step index, status, and summary for an in-progress dispatched task", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 2,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "tapping button",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBe("step 2 \u2014 running: tapping button");
|
||||
});
|
||||
|
||||
it("renders step index and status when summary is missing", () => {
|
||||
const task = makeTask({
|
||||
status: "assigned",
|
||||
progress_step_index: 0,
|
||||
progress_step_status: "starting",
|
||||
progress_summary: null,
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBe("step 0 \u2014 starting");
|
||||
});
|
||||
|
||||
it("returns null when the task is dispatched but no progress has been reported", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: null,
|
||||
progress_step_status: null,
|
||||
progress_summary: null,
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a dispatched task with undefined progress fields", () => {
|
||||
const task = makeTask({ status: "dispatched" });
|
||||
// Fields default to null via makeTask; simulate the undefined case.
|
||||
delete (task as Partial<TaskListItem>).progress_step_index;
|
||||
delete (task as Partial<TaskListItem>).progress_step_status;
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides stale progress when the task has reached a terminal status (done)", () => {
|
||||
const task = makeTask({
|
||||
status: "done",
|
||||
progress_step_index: 5,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "stale snapshot before terminal",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides stale progress when the task has reached a terminal status (failed)", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
progress_step_index: 3,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "stale snapshot before failure",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides progress for a queued task that has not started executing", () => {
|
||||
const task = makeTask({
|
||||
status: "queued",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "should not show while queued",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a missing step status even when step index is present", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: null,
|
||||
progress_summary: "partial snapshot",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates a long summary with an ellipsis", () => {
|
||||
const longSummary = "x".repeat(200);
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: "running",
|
||||
progress_summary: longSummary,
|
||||
});
|
||||
const formatted = formatTaskProgress(task);
|
||||
expect(formatted).not.toBeNull();
|
||||
const summaryPart = formatted!.split(": ").slice(1).join(": ");
|
||||
expect(summaryPart.length).toBeLessThan(longSummary.length);
|
||||
expect(summaryPart.endsWith("\u2026")).toBe(true);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace before truncating or rendering", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: "running",
|
||||
progress_summary: " hello world ",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBe("step 1 \u2014 running: hello world");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TaskListItem, TaskStatus } from "./types";
|
||||
|
||||
/**
|
||||
* Statuses that represent an actively executing assignment, where live
|
||||
* in-progress step information is meaningful to surface.
|
||||
*/
|
||||
const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
|
||||
"assigned",
|
||||
"dispatched",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format a task's latest in-progress step snapshot for inline display.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - the task is not actively executing (queued, done, failed) — terminal/queued
|
||||
* status always wins over a stale progress snapshot; or
|
||||
* - no progress has been reported yet (`progress_step_index` is null/undefined
|
||||
* or `progress_step_status` is missing).
|
||||
*
|
||||
* Otherwise returns a compact string like `"step 2 — running: tapping button"`.
|
||||
* The summary is truncated to a display-friendly length with a Unicode ellipsis
|
||||
* when it exceeds the limit.
|
||||
*/
|
||||
export function formatTaskProgress(
|
||||
task: Pick<
|
||||
TaskListItem,
|
||||
| "status"
|
||||
| "progress_step_index"
|
||||
| "progress_step_status"
|
||||
| "progress_summary"
|
||||
>,
|
||||
maxSummaryLength = 120,
|
||||
): string | null {
|
||||
if (!ACTIVE_STATUSES.has(task.status)) return null;
|
||||
if (
|
||||
task.progress_step_index === null ||
|
||||
task.progress_step_index === undefined ||
|
||||
!task.progress_step_status
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const summary = task.progress_summary ?? "";
|
||||
const trimmed = summary.trim();
|
||||
const display =
|
||||
trimmed.length > maxSummaryLength
|
||||
? `${trimmed.slice(0, maxSummaryLength - 1).trimEnd()}\u2026`
|
||||
: trimmed;
|
||||
return `step ${task.progress_step_index} \u2014 ${task.progress_step_status}${
|
||||
display ? `: ${display}` : ""
|
||||
}`;
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export interface TaskListItem {
|
||||
target_host_id: string | null;
|
||||
target_device_id: string | null;
|
||||
created_at: string;
|
||||
progress_step_index?: number | null;
|
||||
progress_step_status?: string | null;
|
||||
progress_summary?: string | null;
|
||||
progress_updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskSubmissionPayload {
|
||||
@@ -178,3 +182,17 @@ export interface LlmProviderProfileListResponse {
|
||||
settings: LlmProviderSettings;
|
||||
items: LlmProviderProfile[];
|
||||
}
|
||||
|
||||
export interface PlannerDecisionItem {
|
||||
step_index: number;
|
||||
attempt: number;
|
||||
system_prompt: string;
|
||||
user_prompt: string;
|
||||
tool_name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlannerDecisionListResponse {
|
||||
items: PlannerDecisionItem[];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
getTaskAttempts,
|
||||
getTaskPlannerDecisions,
|
||||
listTasks,
|
||||
listDevices,
|
||||
listHosts,
|
||||
@@ -16,7 +17,10 @@ import type {
|
||||
TaskStatus,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
} from "../types";
|
||||
import { formatTaskProgress } from "../taskProgress";
|
||||
import { computePlannerHistoryState } from "../plannerHistory";
|
||||
|
||||
const props = defineProps<{ canSubmit: boolean }>();
|
||||
|
||||
@@ -48,6 +52,9 @@ const submitCapabilityTags = ref("");
|
||||
const submitting = ref(false);
|
||||
const hosts = ref<HostRecord[]>([]);
|
||||
const devices = ref<DeviceRecord[]>([]);
|
||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||
const plannerLoading = ref(false);
|
||||
const plannerError = ref("");
|
||||
const availableDevices = computed(() =>
|
||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||
);
|
||||
@@ -75,6 +82,7 @@ async function refresh() {
|
||||
if (!stillPresent) {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -129,21 +137,51 @@ async function selectTask(task: TaskListItem) {
|
||||
selectedTask.value = task;
|
||||
attempts.value = [];
|
||||
attemptsError.value = "";
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
attemptsLoading.value = true;
|
||||
|
||||
// Fetch attempts first so we know which attempt numbers exist.
|
||||
let loadedAttempts: TaskAttempt[] = [];
|
||||
try {
|
||||
attempts.value = await getTaskAttempts(task.id);
|
||||
loadedAttempts = await getTaskAttempts(task.id);
|
||||
attempts.value = loadedAttempts;
|
||||
} catch (err) {
|
||||
if (err instanceof CloudApiError && err.status === 404) {
|
||||
// Task was deleted between list and detail load.
|
||||
selectedTask.value = null;
|
||||
await refresh();
|
||||
} else {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
attemptsLoading.value = false;
|
||||
|
||||
// Fetch planner decisions for the latest attempt (if any).
|
||||
const latestAttempt = loadedAttempts.length
|
||||
? Math.max(...loadedAttempts.map((a) => a.attempt))
|
||||
: task.attempt_count > 0
|
||||
? task.attempt_count - 1
|
||||
: null;
|
||||
|
||||
if (latestAttempt !== null) {
|
||||
plannerLoading.value = true;
|
||||
try {
|
||||
plannerDecisions.value = await getTaskPlannerDecisions(task.id, latestAttempt);
|
||||
} catch (err) {
|
||||
// 404 on the task is already handled above; other errors are non-fatal.
|
||||
if (!(err instanceof CloudApiError && err.status === 404)) {
|
||||
handleError(err, "failed to load planner decisions");
|
||||
plannerError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
} finally {
|
||||
plannerLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +208,8 @@ function goNextPage() {
|
||||
function clearSelection() {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
@@ -212,6 +252,30 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
return String(attempt.terminal_result);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTaskProgress = computed(() =>
|
||||
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
||||
);
|
||||
|
||||
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
||||
if (!selectedTask.value?.assigned_host_id) return null;
|
||||
const host = hosts.value.find(
|
||||
(h) => h.host_id === selectedTask.value?.assigned_host_id,
|
||||
);
|
||||
return host?.planner_transport ?? null;
|
||||
});
|
||||
|
||||
const plannerHistoryState = computed(() =>
|
||||
computePlannerHistoryState(plannerDecisions.value, selectedHostTransport.value),
|
||||
);
|
||||
|
||||
function formatArguments(args: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(args, null, 2);
|
||||
} catch {
|
||||
return String(args);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -305,6 +369,13 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<div v-if="task.failure_reason" class="dim">
|
||||
{{ task.failure_reason }}
|
||||
</div>
|
||||
<div
|
||||
v-if="formatTaskProgress(task)"
|
||||
class="task-progress"
|
||||
:title="formatTaskProgress(task) ?? ''"
|
||||
>
|
||||
{{ formatTaskProgress(task) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="task.assigned_device_id">
|
||||
@@ -363,6 +434,10 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<strong class="text-danger">Failure reason:</strong>
|
||||
{{ selectedTask.failure_reason }}
|
||||
</p>
|
||||
<p v-if="selectedTaskProgress" class="task-progress-detail">
|
||||
<strong>Current step:</strong>
|
||||
<code>{{ selectedTaskProgress }}</code>
|
||||
</p>
|
||||
|
||||
<h3>Attempt history</h3>
|
||||
<div v-if="attemptsLoading" class="muted">loading attempts…</div>
|
||||
@@ -402,6 +477,93 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="muted">No attempts recorded for this task yet.</div>
|
||||
|
||||
<h3>LLM interaction history</h3>
|
||||
<div v-if="plannerLoading" class="muted">loading planner decisions…</div>
|
||||
<div v-else-if="plannerError" class="notice error">{{ plannerError }}</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'populated'" class="planner-history">
|
||||
<div
|
||||
v-for="decision in plannerHistoryState.decisions"
|
||||
:key="`${decision.attempt}-${decision.step_index}`"
|
||||
class="planner-decision"
|
||||
>
|
||||
<div class="planner-decision-header">
|
||||
<span class="status-badge queued">step {{ decision.step_index }}</span>
|
||||
<code>{{ decision.tool_name }}</code>
|
||||
<span class="dim">{{ formatTime(decision.created_at) }}</span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>User prompt</summary>
|
||||
<pre class="planner-prompt">{{ decision.user_prompt }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.system_prompt">
|
||||
<summary>System prompt</summary>
|
||||
<pre class="planner-prompt">{{ decision.system_prompt }}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Arguments</summary>
|
||||
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
|
||||
No planner decisions reported yet for this task.
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'direct_transport_hidden'" class="muted">
|
||||
This host uses the <code>direct</code> planner transport and does not report LLM interactions to Cloud.
|
||||
</div>
|
||||
<div v-else class="muted">
|
||||
Host transport unknown; no planner decisions available.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-progress {
|
||||
margin-top: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-progress-detail {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.task-progress-detail code {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.planner-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.planner-decision {
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.planner-decision-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.planner-prompt {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user