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
This commit is contained in:
Asher
2025-06-20 14:34:31 -08:00
committed by GitHub
parent 0258f1d771
commit 0a483ea2b7
20 changed files with 115 additions and 16 deletions
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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: "",
},
+2
View File
@@ -18081,11 +18081,13 @@ const docTemplate = `{
"type": "string",
"enum": [
"working",
"idle",
"complete",
"failure"
],
"x-enum-varnames": [
"WorkspaceAppStatusStateWorking",
"WorkspaceAppStatusStateIdle",
"WorkspaceAppStatusStateComplete",
"WorkspaceAppStatusStateFailure"
]
+2 -1
View File
@@ -16522,9 +16522,10 @@
},
"codersdk.WorkspaceAppStatusState": {
"type": "string",
"enum": ["working", "complete", "failure"],
"enum": ["working", "idle", "complete", "failure"],
"x-enum-varnames": [
"WorkspaceAppStatusStateWorking",
"WorkspaceAppStatusStateIdle",
"WorkspaceAppStatusStateComplete",
"WorkspaceAppStatusStateFailure"
]
+2 -1
View File
@@ -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 (
@@ -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;
@@ -0,0 +1 @@
ALTER TYPE workspace_app_status_state ADD VALUE IF NOT EXISTS 'idle';
+4 -1
View File
@@ -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,
}
}
+4 -1
View File
@@ -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.",
+3 -3
View File
@@ -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),
},
},
+1
View File
@@ -19,6 +19,7 @@ type WorkspaceAppStatusState string
const (
WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working"
WorkspaceAppStatusStateIdle WorkspaceAppStatusState = "idle"
WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete"
WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure"
)
+2
View File
@@ -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` |
+1
View File
@@ -9686,6 +9686,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| Value |
|------------|
| `working` |
| `idle` |
| `complete` |
| `failure` |
+2
View File
@@ -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` |
+6 -1
View File
@@ -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",
];
@@ -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<AppStatusStateIconProps> = ({
const className = cn(["size-4 shrink-0", customClassName]);
switch (state) {
case "idle":
return (
<SquareIcon className={cn(["text-content-secondary", className])} />
);
case "complete":
return (
<CircleCheckIcon className={cn(["text-content-success", className])} />
@@ -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: {
@@ -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 (
<div className="flex flex-col text-content-secondary">
<TooltipProvider>
@@ -40,11 +42,11 @@ export const WorkspaceAppStatus = ({
})}
/>
<span className="whitespace-nowrap max-w-72 overflow-hidden text-ellipsis text-sm text-content-primary font-medium">
{status.message}
{message}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{status.message}</TooltipContent>
<TooltipContent>{message}</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-xs first-letter:uppercase block pl-6">
@@ -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([
+3 -2
View File
@@ -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<AppStatusesProps> = ({
<div className="text-sm font-medium text-content-primary flex items-center gap-2 ">
<AppStatusStateIcon state={latestStatus.state} latest />
<span className="block flex-1 whitespace-nowrap overflow-hidden text-ellipsis">
{latestStatus.message}
{latestStatus.message || capitalize(latestStatus.state)}
</span>
</div>
<span className="text-xs text-content-secondary first-letter:uppercase block pl-[26px]">
@@ -160,7 +161,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
latest={false}
className="size-icon-xs w-[18px]"
/>
{status.message}
{status.message || capitalize(status.state)}
</span>
<span className="text-2xs text-content-secondary first-letter:uppercase block pl-[26px]">
{formattedTimestamp}