From 0a483ea2b7ff99c878b6d3abc4783bda8f0dbf22 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 20 Jun 2025 14:34:31 -0800 Subject: [PATCH] feat: add idle app status (#18415) "Idle" is more accurate than "complete" since: 1. AgentAPI only knows if the screen is active; it has no way of knowing if the task is complete. 2. The LLM might be done with its current prompt, but that does not mean the task is complete either (it likely needs refinement). The "complete" state will be reserved for future definition. Additionally, in the case where the screen goes idle but the LLM never reported a status update, we can get an idle icon without a message, and it looks kinda janky in the UI so if there is no message I display the state text. Closes https://github.com/coder/internal/issues/699 --- cli/exp_mcp.go | 4 +-- cli/exp_mcp_test.go | 4 +-- coderd/apidoc/docs.go | 2 ++ coderd/apidoc/swagger.json | 3 +- coderd/database/dump.sql | 3 +- .../000340_workspace_app_status_idle.down.sql | 15 ++++++++ .../000340_workspace_app_status_idle.up.sql | 1 + coderd/database/models.go | 5 ++- coderd/workspaceagents.go | 5 ++- codersdk/toolsdk/toolsdk.go | 6 ++-- codersdk/workspaceapps.go | 1 + docs/reference/api/builds.md | 2 ++ docs/reference/api/schemas.md | 1 + docs/reference/api/templates.md | 2 ++ site/src/api/typesGenerated.ts | 7 +++- site/src/modules/apps/AppStatusStateIcon.tsx | 5 +++ .../WorkspaceAppStatus.stories.tsx | 20 +++++++++++ .../WorkspaceAppStatus/WorkspaceAppStatus.tsx | 6 ++-- .../WorkspacePage/AppStatuses.stories.tsx | 34 +++++++++++++++++++ site/src/pages/WorkspacePage/AppStatuses.tsx | 5 +-- 20 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 coderd/database/migrations/000340_workspace_app_status_idle.down.sql create mode 100644 coderd/database/migrations/000340_workspace_app_status_idle.up.sql diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index d487af5691..0a1c9fcbea 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -585,10 +585,10 @@ func (s *mcpServer) startWatcher(ctx context.Context, inv *serpent.Invocation) { case event := <-eventsCh: switch ev := event.(type) { case agentapi.EventStatusChange: - // If the screen is stable, assume complete. + // If the screen is stable, report idle. state := codersdk.WorkspaceAppStatusStateWorking if ev.Status == agentapi.StatusStable { - state = codersdk.WorkspaceAppStatusStateComplete + state = codersdk.WorkspaceAppStatusStateIdle } err := s.queue.Push(taskReport{ state: state, diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 08d6fbc4e2..bcfafb0204 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -900,7 +900,7 @@ func TestExpMcpReporter(t *testing.T) { { event: makeStatusEvent(agentapi.StatusStable), expected: &codersdk.WorkspaceAppStatus{ - State: codersdk.WorkspaceAppStatusStateComplete, + State: codersdk.WorkspaceAppStatusStateIdle, Message: "doing work", URI: "https://dev.coder.com", }, @@ -948,7 +948,7 @@ func TestExpMcpReporter(t *testing.T) { { event: makeStatusEvent(agentapi.StatusStable), expected: &codersdk.WorkspaceAppStatus{ - State: codersdk.WorkspaceAppStatusStateComplete, + State: codersdk.WorkspaceAppStatusStateIdle, Message: "oops", URI: "", }, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1d175333c1..6844d166d8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18081,11 +18081,13 @@ const docTemplate = `{ "type": "string", "enum": [ "working", + "idle", "complete", "failure" ], "x-enum-varnames": [ "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateIdle", "WorkspaceAppStatusStateComplete", "WorkspaceAppStatusStateFailure" ] diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9d00a7ba34..35d39cfbe3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16522,9 +16522,10 @@ }, "codersdk.WorkspaceAppStatusState": { "type": "string", - "enum": ["working", "complete", "failure"], + "enum": ["working", "idle", "complete", "failure"], "x-enum-varnames": [ "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateIdle", "WorkspaceAppStatusStateComplete", "WorkspaceAppStatusStateFailure" ] diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2a94ef0fe7..17b16abc57 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -324,7 +324,8 @@ CREATE TYPE workspace_app_open_in AS ENUM ( CREATE TYPE workspace_app_status_state AS ENUM ( 'working', 'complete', - 'failure' + 'failure', + 'idle' ); CREATE TYPE workspace_transition AS ENUM ( diff --git a/coderd/database/migrations/000340_workspace_app_status_idle.down.sql b/coderd/database/migrations/000340_workspace_app_status_idle.down.sql new file mode 100644 index 0000000000..a5d2095b1c --- /dev/null +++ b/coderd/database/migrations/000340_workspace_app_status_idle.down.sql @@ -0,0 +1,15 @@ +-- It is not possible to delete a value from an enum, so we have to recreate it. +CREATE TYPE old_workspace_app_status_state AS ENUM ('working', 'complete', 'failure'); + +-- Convert the new "idle" state into "complete". This means we lose some +-- information when downgrading, but this is necessary to swap to the old enum. +UPDATE workspace_app_statuses SET state = 'complete' WHERE state = 'idle'; + +-- Swap to the old enum. +ALTER TABLE workspace_app_statuses +ALTER COLUMN state TYPE old_workspace_app_status_state +USING (state::text::old_workspace_app_status_state); + +-- Drop the new enum and rename the old one to the final name. +DROP TYPE workspace_app_status_state; +ALTER TYPE old_workspace_app_status_state RENAME TO workspace_app_status_state; diff --git a/coderd/database/migrations/000340_workspace_app_status_idle.up.sql b/coderd/database/migrations/000340_workspace_app_status_idle.up.sql new file mode 100644 index 0000000000..1630e3580f --- /dev/null +++ b/coderd/database/migrations/000340_workspace_app_status_idle.up.sql @@ -0,0 +1 @@ +ALTER TYPE workspace_app_status_state ADD VALUE IF NOT EXISTS 'idle'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 6a571ffc1d..ce65c4c559 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2628,6 +2628,7 @@ const ( WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" + WorkspaceAppStatusStateIdle WorkspaceAppStatusState = "idle" ) func (e *WorkspaceAppStatusState) Scan(src interface{}) error { @@ -2669,7 +2670,8 @@ func (e WorkspaceAppStatusState) Valid() bool { switch e { case WorkspaceAppStatusStateWorking, WorkspaceAppStatusStateComplete, - WorkspaceAppStatusStateFailure: + WorkspaceAppStatusStateFailure, + WorkspaceAppStatusStateIdle: return true } return false @@ -2680,6 +2682,7 @@ func AllWorkspaceAppStatusStateValues() []WorkspaceAppStatusState { WorkspaceAppStatusStateWorking, WorkspaceAppStatusStateComplete, WorkspaceAppStatusStateFailure, + WorkspaceAppStatusStateIdle, } } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index ed3f554a89..8282eb9e7d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -359,7 +359,10 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req } switch req.State { - case codersdk.WorkspaceAppStatusStateComplete, codersdk.WorkspaceAppStatusStateFailure, codersdk.WorkspaceAppStatusStateWorking: // valid states + case codersdk.WorkspaceAppStatusStateComplete, + codersdk.WorkspaceAppStatusStateFailure, + codersdk.WorkspaceAppStatusStateWorking, + codersdk.WorkspaceAppStatusStateIdle: // valid states default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid state provided.", diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index bb1649efa1..3b99212400 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -191,7 +191,7 @@ Bad Tasks Use the "state" field to indicate your progress. Periodically report progress with state "working" to keep the user updated. It is not possible to send too many updates! -ONLY report a "complete" or "failure" state if you have FULLY completed the task. +ONLY report an "idle" or "failure" state if you have FULLY completed the task. `, Schema: aisdk.Schema{ Properties: map[string]any{ @@ -205,10 +205,10 @@ ONLY report a "complete" or "failure" state if you have FULLY completed the task }, "state": map[string]any{ "type": "string", - "description": "The state of your task. This can be one of the following: working, complete, or failure. Select the state that best represents your current progress.", + "description": "The state of your task. This can be one of the following: working, idle, or failure. Select the state that best represents your current progress.", "enum": []string{ string(codersdk.WorkspaceAppStatusStateWorking), - string(codersdk.WorkspaceAppStatusStateComplete), + string(codersdk.WorkspaceAppStatusStateIdle), string(codersdk.WorkspaceAppStatusStateFailure), }, }, diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index 556b3adb27..6e95377bba 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -19,6 +19,7 @@ type WorkspaceAppStatusState string const ( WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" + WorkspaceAppStatusStateIdle WorkspaceAppStatusState = "idle" WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" ) diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 2a0e4b2ede..c47b89d0bb 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -933,6 +933,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | @@ -1695,6 +1696,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8f548478e2..e0999f6bb3 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9686,6 +9686,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Value | |------------| | `working` | +| `idle` | | `complete` | | `failure` | diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index d695be4122..85e865d8b4 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2557,6 +2557,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | @@ -3233,6 +3234,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 98338c24bb..d536ac3a0f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3622,11 +3622,16 @@ export interface WorkspaceAppStatus { } // From codersdk/workspaceapps.go -export type WorkspaceAppStatusState = "complete" | "failure" | "working"; +export type WorkspaceAppStatusState = + | "complete" + | "failure" + | "idle" + | "working"; export const WorkspaceAppStatusStates: WorkspaceAppStatusState[] = [ "complete", "failure", + "idle", "working", ]; diff --git a/site/src/modules/apps/AppStatusStateIcon.tsx b/site/src/modules/apps/AppStatusStateIcon.tsx index 829a828823..f713f49ed2 100644 --- a/site/src/modules/apps/AppStatusStateIcon.tsx +++ b/site/src/modules/apps/AppStatusStateIcon.tsx @@ -5,6 +5,7 @@ import { CircleAlertIcon, CircleCheckIcon, HourglassIcon, + SquareIcon, TriangleAlertIcon, } from "lucide-react"; import type { FC } from "react"; @@ -26,6 +27,10 @@ export const AppStatusStateIcon: FC = ({ const className = cn(["size-4 shrink-0", customClassName]); switch (state) { + case "idle": + return ( + + ); case "complete": return ( diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx index d95444e658..0e229467b9 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx @@ -39,6 +39,26 @@ export const Working: Story = { }, }; +export const Idle: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "idle", + message: "Done for now", + }, + }, +}; + +export const NoMessage: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "idle", + message: "", + }, + }, +}; + export const LongMessage: Story = { args: { status: { diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx index 0b999f5440..587ae9f5b0 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx @@ -5,6 +5,7 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; +import capitalize from "lodash/capitalize"; import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon"; import { cn } from "utils/cn"; @@ -25,6 +26,7 @@ export const WorkspaceAppStatus = ({ ); } + const message = status.message || capitalize(status.state); return (
@@ -40,11 +42,11 @@ export const WorkspaceAppStatus = ({ })} /> - {status.message} + {message}
- {status.message} + {message} diff --git a/site/src/pages/WorkspacePage/AppStatuses.stories.tsx b/site/src/pages/WorkspacePage/AppStatuses.stories.tsx index 90be0f194f..c7ec5eb56f 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.stories.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.stories.tsx @@ -48,6 +48,40 @@ export const WorkingState: Story = { }, }; +export const IdleState: Story = { + args: { + agent: mockAgent([ + { + ...MockWorkspaceAppStatus, + id: "status-8", + icon: "", + message: "Done for now", + created_at: createTimestamp(5, 20), + uri: "", + state: "idle" as const, + }, + ...MockWorkspaceAppStatuses, + ]), + }, +}; + +export const NoMessage: Story = { + args: { + agent: mockAgent([ + { + ...MockWorkspaceAppStatus, + id: "status-8", + icon: "", + message: "", + created_at: createTimestamp(5, 20), + uri: "", + state: "idle" as const, + }, + ...MockWorkspaceAppStatuses, + ]), + }, +}; + export const LongStatusText: Story = { args: { agent: mockAgent([ diff --git a/site/src/pages/WorkspacePage/AppStatuses.tsx b/site/src/pages/WorkspacePage/AppStatuses.tsx index 35d4db46c3..95e3f9c95a 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.tsx @@ -12,6 +12,7 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; +import capitalize from "lodash/capitalize"; import { timeFrom } from "utils/time"; import { @@ -77,7 +78,7 @@ export const AppStatuses: FC = ({
- {latestStatus.message} + {latestStatus.message || capitalize(latestStatus.state)}
@@ -160,7 +161,7 @@ export const AppStatuses: FC = ({ latest={false} className="size-icon-xs w-[18px]" /> - {status.message} + {status.message || capitalize(status.state)} {formattedTimestamp}