diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 70b6fcc7e907..eea0066deed0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1257,21 +1257,25 @@ public struct ProgressCardPutParams: Codable, Sendable { public let sessionkey: String public let markdown: String? public let plan: [ProgressCardStep]? + public let expectedrevision: Int? public init( sessionkey: String, markdown: String? = nil, - plan: [ProgressCardStep]? = nil) + plan: [ProgressCardStep]? = nil, + expectedrevision: Int? = nil) { self.sessionkey = sessionkey self.markdown = markdown self.plan = plan + self.expectedrevision = expectedrevision } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case markdown case plan + case expectedrevision = "expectedRevision" } } diff --git a/packages/gateway-protocol/src/schema/progress-card.ts b/packages/gateway-protocol/src/schema/progress-card.ts index 6a6c8fc68b54..9296cdde32d6 100644 --- a/packages/gateway-protocol/src/schema/progress-card.ts +++ b/packages/gateway-protocol/src/schema/progress-card.ts @@ -43,6 +43,7 @@ export const ProgressCardPutParamsSchema = closedObject({ sessionKey: NonEmptyString, markdown: Type.Optional(Type.String()), plan: Type.Optional(Type.Array(ProgressCardStepSchema, { maxItems: PROGRESS_CARD_MAX_STEPS })), + expectedRevision: Type.Optional(Type.Integer({ minimum: 1 })), }); export type ProgressCardPutParams = Static; diff --git a/src/gateway/progress-card-store.ts b/src/gateway/progress-card-store.ts index 632062f1ae0c..7b7b5c766901 100644 --- a/src/gateway/progress-card-store.ts +++ b/src/gateway/progress-card-store.ts @@ -14,7 +14,7 @@ export type ProgressCardStore = { get(sessionKey: string): ProgressCard | null; put( sessionKey: string, - input: { markdown?: string; steps?: ProgressCardStep[] }, + input: { markdown?: string; steps?: ProgressCardStep[]; expectedRevision?: number }, ): { card: ProgressCard | null }; }; diff --git a/src/gateway/server-methods/progress-card.test.ts b/src/gateway/server-methods/progress-card.test.ts index 77ac175d0508..4a0180ac1b01 100644 --- a/src/gateway/server-methods/progress-card.test.ts +++ b/src/gateway/server-methods/progress-card.test.ts @@ -18,7 +18,11 @@ function createHarness() { const store: ProgressCardStore = { get: (sessionKey) => cards.get(sessionKey) ?? null, put: (sessionKey, input) => { + const current = cards.get(sessionKey); if (!input.markdown && !input.steps?.length) { + if (input.expectedRevision !== undefined && current?.revision !== input.expectedRevision) { + return { card: current ?? null }; + } cards.delete(sessionKey); return { card: null }; } @@ -157,4 +161,34 @@ describe("progress card gateway methods", () => { revision: null, }); }); + + it("dismisses only the matching completed revision", async () => { + const { broadcast, invoke } = createHarness(); + await invoke("progressCard.put", { + sessionKey: "agent:main:main", + plan: [{ step: "Done", status: "completed" }], + }); + broadcast.mockClear(); + + const stale = await invoke("progressCard.put", { + sessionKey: "agent:main:main", + expectedRevision: 2, + }); + const dismissed = await invoke("progressCard.put", { + sessionKey: "agent:main:main", + expectedRevision: 1, + }); + + expect(stale).toHaveBeenCalledWith( + true, + { card: expect.objectContaining({ revision: 1 }) }, + undefined, + ); + expect(dismissed).toHaveBeenCalledWith(true, { card: null }, undefined); + expect(broadcast).toHaveBeenCalledOnce(); + expect(broadcast).toHaveBeenCalledWith("progressCard.changed", { + sessionKey: "agent:main:main", + revision: null, + }); + }); }); diff --git a/src/gateway/server-methods/progress-card.ts b/src/gateway/server-methods/progress-card.ts index 1e23aa22984e..1d445d04f7c2 100644 --- a/src/gateway/server-methods/progress-card.ts +++ b/src/gateway/server-methods/progress-card.ts @@ -71,16 +71,32 @@ export function createProgressCardHandlers( respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); return; } + if (params.expectedRevision !== undefined && (input.markdown || input.steps?.length)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "expectedRevision is only valid when clearing a card", + ), + ); + return; + } const sessionKey = resolveProgressCardSessionKey(params.sessionKey, context, respond); if (!sessionKey) { return; } try { - const result = store.put(sessionKey, input); - context.broadcast("progressCard.changed", { - sessionKey, - revision: result.card?.revision ?? null, + const result = store.put(sessionKey, { + ...input, + expectedRevision: params.expectedRevision, }); + if (params.expectedRevision === undefined || result.card === null) { + context.broadcast("progressCard.changed", { + sessionKey, + revision: result.card?.revision ?? null, + }); + } respond(true, result, undefined); } catch (error) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(error))); diff --git a/src/session-cards/progress-card-store.test.ts b/src/session-cards/progress-card-store.test.ts index 57ac69d8a120..3273aef23580 100644 --- a/src/session-cards/progress-card-store.test.ts +++ b/src/session-cards/progress-card-store.test.ts @@ -88,4 +88,42 @@ describe("session progress card store", () => { expect(readSessionProgressCard(db, SESSION_KEY)).toBeNull(); }); + + it("dismisses only a completed card at the expected revision", () => { + writeSessionProgressCard(db, SESSION_KEY, { + steps: [{ step: "Done", status: "completed" }], + }); + + expect(writeSessionProgressCard(db, SESSION_KEY, { expectedRevision: 2 })).toEqual({ + card: expect.objectContaining({ revision: 1 }), + }); + expect(readSessionProgressCard(db, SESSION_KEY)).not.toBeNull(); + expect(writeSessionProgressCard(db, SESSION_KEY, { expectedRevision: 1 })).toEqual({ + cleared: true, + }); + expect(readSessionProgressCard(db, SESSION_KEY)).toBeNull(); + + expect( + writeSessionProgressCard(db, SESSION_KEY, { + steps: [{ step: "New work", status: "in_progress" }], + }), + ).toEqual({ + card: expect.objectContaining({ revision: 3 }), + }); + expect(writeSessionProgressCard(db, SESSION_KEY, { expectedRevision: 1 })).toEqual({ + card: expect.objectContaining({ revision: 3 }), + }); + }); + + it("does not dismiss an active or note-only card", () => { + writeSessionProgressCard(db, SESSION_KEY, { steps: STEPS }); + expect(writeSessionProgressCard(db, SESSION_KEY, { expectedRevision: 1 })).toEqual({ + card: expect.objectContaining({ revision: 1 }), + }); + + writeSessionProgressCard(db, SESSION_KEY, { markdown: "Still relevant" }); + expect(writeSessionProgressCard(db, SESSION_KEY, { expectedRevision: 2 })).toEqual({ + card: expect.objectContaining({ revision: 2 }), + }); + }); }); diff --git a/src/session-cards/progress-card-store.ts b/src/session-cards/progress-card-store.ts index 24a0b7144ed7..97b874c2fe63 100644 --- a/src/session-cards/progress-card-store.ts +++ b/src/session-cards/progress-card-store.ts @@ -61,8 +61,11 @@ function selectProgressCard(db: DatabaseSync, sessionKey: string): StoredProgres ); } -function rowToProgressCard(row: StoredProgressCardRow): ProgressCard { +function rowToProgressCard(row: StoredProgressCardRow): ProgressCard | null { const steps = row.steps_json ? readStoredSteps(row.steps_json) : undefined; + if (!row.markdown && !steps?.length) { + return null; + } return { sessionKey: row.session_key, revision: row.revision, @@ -112,19 +115,41 @@ export function readSessionProgressCard( export function writeSessionProgressCard( dbPathOrDb: ProgressCardDatabaseInput, sessionKey: string, - input: { markdown?: string; steps?: ProgressCardStep[] }, -): { card: ProgressCard } | { cleared: true } { + input: { markdown?: string; steps?: ProgressCardStep[]; expectedRevision?: number }, +): { card: ProgressCard | null } | { cleared: true } { return withProgressCardDatabase(dbPathOrDb, false, (db, label) => { - const write = (): { card: ProgressCard } | { cleared: true } => { + const write = (): { card: ProgressCard | null } | { cleared: true } => { ensureOpenClawAgentProgressCardSchemaInTransaction(db); const kysely = getNodeSqliteKysely(db); const markdown = input.markdown?.trim() ? input.markdown : undefined; const steps = input.steps && input.steps.length > 0 ? input.steps : undefined; if (!markdown && !steps) { - executeSqliteQuerySync( - db, - kysely.deleteFrom("session_progress_cards").where("session_key", "=", sessionKey), - ); + const previous = selectProgressCard(db, sessionKey); + if (input.expectedRevision !== undefined) { + const current = previous ? rowToProgressCard(previous) : null; + if ( + !previous || + previous.revision !== input.expectedRevision || + !current?.steps?.length || + current.steps.some((step) => step.status !== "completed") + ) { + return { card: current }; + } + } + if (previous) { + executeSqliteQuerySync( + db, + kysely + .updateTable("session_progress_cards") + .set({ + markdown: null, + steps_json: null, + revision: previous.revision + 1, + updated_at: Date.now(), + }) + .where("session_key", "=", sessionKey), + ); + } return { cleared: true }; } const previous = selectProgressCard(db, sessionKey); diff --git a/ui/src/components/session-progress-card-controller.ts b/ui/src/components/session-progress-card-controller.ts index 0e9c50938b77..8dee0d1e90a5 100644 --- a/ui/src/components/session-progress-card-controller.ts +++ b/ui/src/components/session-progress-card-controller.ts @@ -28,6 +28,9 @@ export class SessionProgressCardController implements ReactiveController { return this.store?.get(this.sessionKey) ?? null; } + dismiss = (card: ProgressCard): Promise => + this.store?.dismiss(card) ?? Promise.resolve(false); + hostUpdate(): void { this.synchronize(); } diff --git a/ui/src/components/session-progress-card.ts b/ui/src/components/session-progress-card.ts index 10d1aef91b86..da215533b635 100644 --- a/ui/src/components/session-progress-card.ts +++ b/ui/src/components/session-progress-card.ts @@ -2,6 +2,7 @@ import type { ProgressCard, ProgressCardStep } from "@openclaw/gateway-protocol" import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../i18n/index.ts"; +import { icons } from "./icons.ts"; import { toSanitizedMarkdownHtml } from "./markdown.ts"; type SessionProgressCardPlacement = "board" | "composer" | "hovercard" | "rail"; @@ -69,6 +70,7 @@ function renderBody(card: ProgressCard) { export function renderSessionProgressCard( card: ProgressCard | null | undefined, placement: SessionProgressCardPlacement, + onDismiss?: (card: ProgressCard) => void, ) { if (!card) { return nothing; @@ -80,6 +82,24 @@ export function renderSessionProgressCard( total: String(counts.total), }) : t("sessionProgressCard.noteLabel"); + const dismissible = Boolean( + onDismiss && card.steps?.length && card.steps.every((step) => step.status === "completed"), + ); + const dismiss = dismissible + ? html`` + : nothing; if (placement === "composer") { const current = currentProgressStep(card.steps ?? []); return html`
${counts.completed}/${counts.total}` : nothing} + ${dismiss} ${renderBody(card)}
`; @@ -108,7 +129,10 @@ export function renderSessionProgressCard( ${counts ? html`
${t("sessionProgressCard.title")} - ${counts.completed}/${counts.total} + + ${counts.completed}/${counts.total} + ${dismiss} +
` : nothing} ${renderBody(card)} diff --git a/ui/src/e2e/session-progress-live-placement.e2e.test.ts b/ui/src/e2e/session-progress-live-placement.e2e.test.ts index b7a6f0074e8e..7d760cebb786 100644 --- a/ui/src/e2e/session-progress-live-placement.e2e.test.ts +++ b/ui/src/e2e/session-progress-live-placement.e2e.test.ts @@ -137,4 +137,121 @@ suite.define(() => { }, ); }); + + it("dismisses a completed card across rerender and reload", async () => { + const sessionKey = "agent:main:progress-complete"; + const plan = [ + { step: "Inspected owner", status: "completed" }, + { step: "Implemented fix", status: "completed" }, + { step: "Filed issue", status: "completed" }, + ]; + + for (const colorScheme of ["light", "dark"] as const) { + await suite.withPage( + { + colorScheme, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 560 }, + }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.startup", + "progressCard.get", + "progressCard.put", + ], + methodResponses: { + "progressCard.get": { + card: { + revision: 3, + sessionKey, + steps: plan, + updatedAt: 3, + }, + }, + "progressCard.put": { card: null }, + "sessions.list": chatSessionListResponse([ + { + key: sessionKey, + kind: "direct", + label: "Completed progress", + updatedAt: 3, + }, + ]), + }, + sessionKey, + }); + + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); + await expect.poll(() => gateway.getRequests("progressCard.get")).toHaveLength(1); + const card = page.locator('[data-progress-card-placement="composer"]'); + await expect.poll(() => card.isVisible()).toBe(true); + await card.locator("summary").click(); + await captureProof(page, `completed-${colorScheme}-before.png`); + + await card.getByRole("button", { name: "Dismiss progress card" }).click(); + const dismissRequest = await gateway.waitForRequest("progressCard.put"); + expect(dismissRequest.params).toEqual({ sessionKey, expectedRevision: 3 }); + await expect.poll(() => card.count()).toBe(0); + + await page.locator("textarea").fill("rerender"); + await expect.poll(() => card.count()).toBe(0); + await gateway.setMethodResponse("progressCard.get", { card: null }); + await page.reload(); + await page.locator("textarea").waitFor({ state: "visible" }); + await expect.poll(() => card.count()).toBe(0); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + await captureProof(page, `completed-${colorScheme}-after.png`); + }, + ); + } + }); + + it("keeps dismissal unavailable to a restricted session viewer", async () => { + const sessionKey = "agent:main:progress-viewer"; + await suite.withPage( + { + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 560 }, + }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "progressCard.get", "progressCard.put"], + hasMultipleSessionSharingIdentities: true, + methodResponses: { + "progressCard.get": { + card: { + revision: 1, + sessionKey, + steps: [{ step: "Completed work", status: "completed" }], + updatedAt: 1, + }, + }, + "sessions.list": chatSessionListResponse([ + { + key: sessionKey, + kind: "direct", + label: "Restricted progress", + sharingRole: "viewer", + updatedAt: 1, + visibility: "suggest", + }, + ]), + }, + sessionKey, + }); + + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); + const card = page.locator('[data-progress-card-placement="composer"]'); + await expect.poll(() => card.isVisible()).toBe(true); + await expect + .poll(() => card.getByRole("button", { name: "Dismiss progress card" }).count()) + .toBe(0); + expect(await gateway.getRequests("progressCard.put")).toHaveLength(0); + }, + ); + }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 336187d1d365..23ed03ead501 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -189,6 +189,8 @@ export const en: TranslationMap = { ariaLabel: "Session progress", title: "Progress", noteLabel: "Progress note", + dismiss: "Dismiss progress card", + dismissFailed: "Could not dismiss the progress card. Try again.", widgetLabel: "Session progress", widgetLoading: "Loading session progress…", widgetEmpty: "No progress card yet", diff --git a/ui/src/lib/session-progress-cards.ts b/ui/src/lib/session-progress-cards.ts index 7ad9bbb581f6..915a843816ec 100644 --- a/ui/src/lib/session-progress-cards.ts +++ b/ui/src/lib/session-progress-cards.ts @@ -1,6 +1,7 @@ import type { ProgressCard, ProgressCardGetResult, + ProgressCardPutResult, ProgressCardStep, } from "@openclaw/gateway-protocol"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; @@ -9,6 +10,7 @@ import type { ApplicationGateway } from "../app/gateway.ts"; import { isGatewayMethodAdvertised } from "./gateway-methods.ts"; const PROGRESS_CARD_GET_METHOD = "progressCard.get"; +const PROGRESS_CARD_PUT_METHOD = "progressCard.put"; const PROGRESS_CARD_CHANGED_EVENT = "progressCard.changed"; const CACHE_LIMIT = 100; @@ -23,6 +25,7 @@ export type SessionProgressCardStore = { watch: (owner: object, sessionKeys: readonly string[]) => void; unwatch: (owner: object) => void; load: (sessionKey: string) => Promise; + dismiss: (card: ProgressCard) => Promise; get: (sessionKey: string) => ProgressCard | null | undefined; getError: (sessionKey: string) => SessionProgressCardLoadError | undefined; subscribe: (listener: () => void) => () => void; @@ -289,6 +292,25 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore { watch, unwatch: (owner) => watch(owner, []), load, + dismiss: async (card) => { + const client = gateway.snapshot.client; + if (!client) { + return false; + } + const result = await client.request(PROGRESS_CARD_PUT_METHOD, { + sessionKey: card.sessionKey, + expectedRevision: card.revision, + }); + const dismissed = result.card === null; + if (dismissed && cache.get(card.sessionKey)?.revision === card.revision) { + remember(card.sessionKey, { card: null, revision: null }); + notify(); + } else if (result.card) { + remember(card.sessionKey, { card: result.card, revision: result.card.revision }); + notify(); + } + return dismissed; + }, get: (sessionKey) => cache.get(sessionKey)?.card, getError: (sessionKey) => errors.get(sessionKey), subscribe: (listener) => { diff --git a/ui/src/pages/chat/chat-pane-embedded-panels.ts b/ui/src/pages/chat/chat-pane-embedded-panels.ts index 7aff80877ea4..a7211c255447 100644 --- a/ui/src/pages/chat/chat-pane-embedded-panels.ts +++ b/ui/src/pages/chat/chat-pane-embedded-panels.ts @@ -33,6 +33,7 @@ type SidebarPanelDefinitionParams = { lastReadAt: number | undefined; pullRequests: ControlUiSessionPullRequest[]; progressCard: ProgressCard | null; + onDismissProgressCard?: (card: ProgressCard) => void; companion: ChatSessionCompanionThread; onCompanionSubmit: (question: string) => void; onCompanionDraftChange: (draft: string) => void; @@ -116,6 +117,7 @@ export function sidebarPanelDefinitions( .startedAt=${params.startedAt} .lastReadAt=${params.lastReadAt} .progressCard=${params.progressCard} + .onDismissProgressCard=${params.onDismissProgressCard} .pullRequests=${params.pullRequests} .companion=${params.companion} .connected=${state?.connected === true} diff --git a/ui/src/pages/chat/chat-pane-layout-render.ts b/ui/src/pages/chat/chat-pane-layout-render.ts index fa27f9493078..afe7d9f486cf 100644 --- a/ui/src/pages/chat/chat-pane-layout-render.ts +++ b/ui/src/pages/chat/chat-pane-layout-render.ts @@ -1,5 +1,8 @@ import { html, nothing } from "lit"; -import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js"; +import type { + ProgressCard, + SessionObserverDigest, +} from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewaySessionRow } from "../../api/types.ts"; import { isDesktopPanelAvailable } from "../../app/app-shell-chrome.ts"; import { ChatPaneBrowserAnnotationRender } from "./chat-pane-browser-annotation-render.ts"; @@ -36,6 +39,7 @@ type ChatPaneLayoutRenderParams = { board: ResolvedBoardView; sidebarLayout: SidebarLayout; progressCardInRail: boolean; + onDismissProgressCard?: (card: ProgressCard) => void; sessionWorkspace: SessionWorkspaceProps; backgroundTasks: BackgroundTasksProps; chatProps: ChatProps; @@ -58,6 +62,7 @@ export abstract class ChatPaneLayoutRender extends ChatPaneBrowserAnnotationRend board, sidebarLayout, progressCardInRail, + onDismissProgressCard, sessionWorkspace, backgroundTasks, chatProps, @@ -118,6 +123,7 @@ export abstract class ChatPaneLayoutRender extends ChatPaneBrowserAnnotationRend lastReadAt: selectedSession?.lastReadAt, pullRequests: this.sessionPullRequests, progressCard: progressCardInRail ? this.progressCard.card : null, + onDismissProgressCard, companion: companionThread, onCompanionSubmit: (question) => void this.submitSessionCompanionQuestion(question), onCompanionDraftChange: (draft) => diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index bd125cffd245..1f080dd9eea8 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -18,6 +18,7 @@ import { } from "../../lib/observer-digest.ts"; import { hasSessionPresenceViewers } from "../../lib/presence-users.ts"; import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts"; +import { showToast } from "../../lib/toast.ts"; import { clearChatHistory } from "./chat-history.ts"; import { resolveChatMessageAccess } from "./chat-message-access.ts"; import { requiresChatModelSetup } from "./chat-model-setup.ts"; @@ -158,6 +159,18 @@ export class ChatPane extends ChatPaneLayoutRender { session: selectedSession, }); const gatewaySnapshot = this.context.gateway.snapshot; + const canDismissProgressCard = + state.connected && + !sessionParticipationBlocked && + hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(gatewaySnapshot, "progressCard.put") === true; + const onDismissProgressCard = canDismissProgressCard + ? (card: NonNullable) => { + void this.progressCard + .dismiss(card) + .catch(() => showToast({ message: t("sessionProgressCard.dismissFailed") })); + } + : undefined; const restartRecoveryTombstoned = selectedSession?.restartRecoveryStatus === "tombstoned"; const multiIdentity = this.hasMultipleIdentities(); const suggestionViewer = @@ -285,6 +298,7 @@ export class ChatPane extends ChatPaneLayoutRender { compactionStatus: state.compactionStatus, fallbackStatus: state.fallbackStatus, progressCard: progressCardInRail ? null : this.progressCard.card, + onDismissProgressCard, gatewayQuestionPrompts: catalogKey || sessionParticipationBlocked ? [] : this.questionPrompts, onGatewayQuestionChange: () => { this.questionPrompts = [...this.questionPrompts]; @@ -547,6 +561,7 @@ export class ChatPane extends ChatPaneLayoutRender { board, sidebarLayout, progressCardInRail, + onDismissProgressCard, sessionWorkspace, backgroundTasks, chatProps: props, diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 2f68f3b1276a..e7e7180ebef2 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -101,6 +101,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps & compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; progressCard?: ProgressCard | null; + onDismissProgressCard?: (card: ProgressCard) => void; gatewayQuestionPrompts?: readonly QuestionPrompt[]; onGatewayQuestionChange?: () => void; onGatewayQuestionSubmit?: ( @@ -392,6 +393,7 @@ export function renderChat(props: ChatProps) { compactionStatus: props.compactionStatus, fallbackStatus: props.fallbackStatus, progressCard: props.progressCard, + onDismissProgressCard: props.onDismissProgressCard, gatewayQuestionPrompts: props.gatewayQuestionPrompts, messages: props.messages, stream: props.stream, diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index a30020ea3224..d7bc20a5ad3a 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -86,6 +86,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & { compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; progressCard?: ProgressCard | null; + onDismissProgressCard?: (card: ProgressCard) => void; gatewayQuestionPrompts?: readonly QuestionPrompt[]; messages: unknown[]; stream: string | null; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index a2dc40e9d661..d4e54049a3d4 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -266,7 +266,11 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ` : nothing} - ${renderSessionProgressCard(props.progressCard, "composer")} + ${renderSessionProgressCard( + props.progressCard, + "composer", + props.onDismissProgressCard, + )} ${renderFallbackIndicator(props.fallbackStatus)} ${renderCompactionIndicator(props.compactionStatus)} ${renderChatGoal(state, activeSession?.goal, { diff --git a/ui/src/pages/chat/components/chat-session-rail.ts b/ui/src/pages/chat/components/chat-session-rail.ts index 96081d6c8f8b..288575ce01aa 100644 --- a/ui/src/pages/chat/components/chat-session-rail.ts +++ b/ui/src/pages/chat/components/chat-session-rail.ts @@ -220,6 +220,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement { @property({ attribute: false }) startedAt?: number; @property({ attribute: false }) lastReadAt?: number; @property({ attribute: false }) progressCard: ProgressCard | null = null; + @property({ attribute: false }) onDismissProgressCard?: (card: ProgressCard) => void; @property({ attribute: false }) pullRequests: ControlUiSessionPullRequest[] = []; @property({ attribute: false }) companion: ChatSessionCompanionThread = { exchanges: [], @@ -626,7 +627,8 @@ export class ChatSessionRailElement extends OpenClawLightDomElement { ${digest ? html`
${this.renderDigestDetails(digest)}
` : nothing} - ${renderSessionProgressCard(this.progressCard, "rail")} ${this.renderThread()} + ${renderSessionProgressCard(this.progressCard, "rail", this.onDismissProgressCard)} + ${this.renderThread()} ${this.companion.exchanges.length === 0 && !this.companion.pendingQuestion ? this.renderStarters() : nothing} diff --git a/ui/src/styles/chat/progress-card.css b/ui/src/styles/chat/progress-card.css index f6d283b63154..025ec041203b 100644 --- a/ui/src/styles/chat/progress-card.css +++ b/ui/src/styles/chat/progress-card.css @@ -179,9 +179,15 @@ text-transform: uppercase; } +.session-progress-card__heading-actions { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + .session-progress-card__summary { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 7px; min-width: 0; @@ -236,6 +242,13 @@ font-variant-numeric: tabular-nums; } +.session-progress-card__dismiss { + width: 22px; + min-width: 22px; + height: 22px; + min-height: 22px; +} + .session-progress-card--composer .session-progress-card__body { padding: 7px 9px 8px; border-top: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border));