SaylorTwift HF Staff commited on
Commit
a070805
·
verified ·
1 Parent(s): 5f40163

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/extensions/canvas-extension-module-loader.test.ts +19 -0
  2. src/extensions/canvas-extension-module-loader.ts +42 -0
  3. src/hooks/use-acp-credential-form.ts +114 -0
  4. src/hooks/use-active-agent-profile.ts +32 -0
  5. src/hooks/use-agent-state.test.tsx +73 -0
  6. src/hooks/use-agent-state.ts +107 -0
  7. src/hooks/use-auto-refresh-files-on-edit.ts +151 -0
  8. src/hooks/use-await-context-compaction.test.ts +178 -0
  9. src/hooks/use-chat-input-model-state.ts +121 -0
  10. src/hooks/use-conversation-overview-git-diff-stats.ts +81 -0
  11. src/hooks/use-debounce.ts +12 -0
  12. src/hooks/use-device-flow.ts +200 -0
  13. src/hooks/use-ensure-active-profile.ts +51 -0
  14. src/hooks/use-handle-plan-click.ts +251 -0
  15. src/hooks/use-handle-runtime-active.ts +10 -0
  16. src/hooks/use-handle-ws-events.ts +63 -0
  17. src/hooks/use-llm-configured.test.tsx +176 -0
  18. src/hooks/use-migrate-enabled-skills.ts +43 -0
  19. src/hooks/use-pinned-home-route.ts +113 -0
  20. src/hooks/use-popover-fixed-placement.ts +56 -0
  21. src/hooks/use-resizable-drawer-width.ts +126 -0
  22. src/hooks/use-skill-installs.ts +33 -0
  23. src/hooks/use-task-list.ts +48 -0
  24. src/hooks/use-tracking.ts +500 -0
  25. src/hooks/use-user-providers.ts +17 -0
  26. src/styles/agent-server-ui-style-scope.ts +117 -0
  27. src/utils/acp-auth-display.ts +46 -0
  28. src/utils/acp-command.ts +189 -0
  29. src/utils/acp-error-codes.ts +29 -0
  30. src/utils/agent-state-emoji.ts +32 -0
  31. src/utils/api-error-message.ts +38 -0
  32. src/utils/automation-activity-log-export.ts +186 -0
  33. src/utils/automation-catalog.ts +185 -0
  34. src/utils/automation-disabled-reason.ts +65 -0
  35. src/utils/automation-export.test.ts +132 -0
  36. src/utils/automation-schedule.ts +262 -0
  37. src/utils/automation-stack-section.ts +2 -0
  38. src/utils/automation-timeout.ts +37 -0
  39. src/utils/cache-utils.ts +61 -0
  40. src/utils/constants.ts +127 -0
  41. src/utils/conversation-archive-status.ts +7 -0
  42. src/utils/conversation-local-storage.ts +437 -0
  43. src/utils/conversation-metrics.ts +79 -0
  44. src/utils/conversation-overview-project-scope.ts +116 -0
  45. src/utils/convert-image-to-base-64.ts +9 -0
  46. src/utils/convert-raw-providers-to-list.ts +14 -0
  47. src/utils/custom-toast-handlers.tsx +150 -0
  48. src/utils/derive-profile-name.ts +49 -0
  49. src/utils/dropdown-classes.ts +90 -0
  50. src/utils/enqueue-home-task-pending-message.ts +30 -0
src/extensions/canvas-extension-module-loader.test.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ assertCanvasExtensionModule,
4
+ InvalidCanvasExtensionModuleError,
5
+ } from "#/extensions/canvas-extension-module-loader";
6
+
7
+ describe("assertCanvasExtensionModule", () => {
8
+ it("accepts a module namespace with an activate export", () => {
9
+ expect(() =>
10
+ assertCanvasExtensionModule({ activate: () => undefined }),
11
+ ).not.toThrow();
12
+ });
13
+
14
+ it("rejects modules that do not implement the host lifecycle", () => {
15
+ expect(() => assertCanvasExtensionModule({ default: {} })).toThrow(
16
+ InvalidCanvasExtensionModuleError,
17
+ );
18
+ });
19
+ });
src/extensions/canvas-extension-module-loader.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CanvasExtensionModule } from "#/types/canvas-extension";
2
+
3
+ export class InvalidCanvasExtensionModuleError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "InvalidCanvasExtensionModuleError";
7
+ }
8
+ }
9
+
10
+ export function assertCanvasExtensionModule(
11
+ value: unknown,
12
+ ): asserts value is CanvasExtensionModule {
13
+ if (
14
+ typeof value !== "object" ||
15
+ value === null ||
16
+ !("activate" in value) ||
17
+ typeof value.activate !== "function"
18
+ ) {
19
+ throw new InvalidCanvasExtensionModuleError(
20
+ 'Canvas Extension entrypoint must export an "activate" function.',
21
+ );
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Import an authenticated, self-contained ESM bundle without putting the
27
+ * Agent Server session key in a URL. The source has already been fetched by
28
+ * Canvas through its authenticated HTTP client.
29
+ */
30
+ export async function loadCanvasExtensionModule(
31
+ source: string,
32
+ ): Promise<CanvasExtensionModule> {
33
+ const blob = new Blob([source], { type: "text/javascript" });
34
+ const moduleUrl = URL.createObjectURL(blob);
35
+ try {
36
+ const imported: unknown = await import(/* @vite-ignore */ moduleUrl);
37
+ assertCanvasExtensionModule(imported);
38
+ return imported;
39
+ } finally {
40
+ URL.revokeObjectURL(moduleUrl);
41
+ }
42
+ }
src/hooks/use-acp-credential-form.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useSearchSecrets } from "#/hooks/query/use-get-secrets";
3
+ import { useSaveAcpSecrets } from "#/hooks/use-save-acp-secrets";
4
+ import {
5
+ getAcpCredentialConflicts,
6
+ getAcpProviderSecrets,
7
+ type ACPProviderSecretField,
8
+ } from "#/constants/acp-providers";
9
+
10
+ export interface AcpCredentialForm {
11
+ /** Credential fields for ``providerKey`` (see {@link getAcpProviderSecrets}). */
12
+ fields: ACPProviderSecretField[];
13
+ /** Current (unsaved) input values, keyed by field name. */
14
+ values: Record<string, string>;
15
+ setValue: (name: string, value: string) => void;
16
+ /** Whether a secret with this name is already saved on the backend. */
17
+ secretExists: (name: string) => boolean;
18
+ /** Typed now (non-blank) or previously saved. */
19
+ hasValueFor: (name: string) => boolean;
20
+ /** ``[credential, conflicting]`` pairs currently both set (typed or saved). */
21
+ conflicts: Array<[string, string]>;
22
+ /** Whether a credential (a ``secret`` field — API key, OAuth token, or
23
+ * file-content blob) is already saved on the backend for this provider. This
24
+ * is the backend-truthful auth signal that works on Docker/cloud, where the
25
+ * host-login probe can't run (agent-canvas#1244). A non-credential field (a
26
+ * base URL or GCP scalar) being set does not count. */
27
+ credentialsConfigured: boolean;
28
+ /** Whether the active backend can materialise file-content (``multiline``)
29
+ * credentials to disk. False on cloud (agent-canvas#1016), where such a
30
+ * credential would be orphaned. */
31
+ consumesFileCredentials: boolean;
32
+ /** At least one field has a non-blank typed value. */
33
+ isDirty: boolean;
34
+ /** Persist the filled fields; resolves ``true`` when everything saved.
35
+ * ``silent`` suppresses the success toast so a caller saving credentials
36
+ * alongside other state can emit a single combined confirmation. */
37
+ save: (options?: { silent?: boolean }) => Promise<boolean>;
38
+ reset: () => void;
39
+ isSaving: boolean;
40
+ }
41
+
42
+ /**
43
+ * Shared state + derived values for the ACP credential forms (the onboarding
44
+ * step and the Settings → Agent section): the provider's field list, the typed
45
+ * values, existing-secret lookups, the credential-conflict pairs, and the save
46
+ * flow ({@link useSaveAcpSecrets}). Values reset when ``providerKey`` changes —
47
+ * typed-but-unsaved input belongs to the previous provider's fields.
48
+ */
49
+ export function useAcpCredentialForm(
50
+ providerKey: string | null | undefined,
51
+ ): AcpCredentialForm {
52
+ const { data: existingSecrets } = useSearchSecrets();
53
+ const fields = React.useMemo(
54
+ () => getAcpProviderSecrets(providerKey),
55
+ [providerKey],
56
+ );
57
+ const [values, setValues] = React.useState<Record<string, string>>({});
58
+
59
+ // Both local and cloud agent-servers materialise file-content credentials
60
+ // (Codex auth.json, Gemini Vertex SA) via the SDK's acp_file_secrets: locally
61
+ // from the user's machine, on cloud from the per-user encrypted secret store
62
+ // routed through agent_context.secrets at conversation start (the cloud
63
+ // backend pins an SDK that materialises reserved file secrets). So a pasted
64
+ // file blob is consumable on every supported backend.
65
+ const consumesFileCredentials = true;
66
+ const { saveFilled, isSaving } = useSaveAcpSecrets(
67
+ fields,
68
+ consumesFileCredentials,
69
+ );
70
+
71
+ React.useEffect(() => {
72
+ setValues({});
73
+ }, [providerKey]);
74
+
75
+ const secretExists = React.useCallback(
76
+ (name: string) =>
77
+ (existingSecrets ?? []).some((secret) => secret.name === name),
78
+ [existingSecrets],
79
+ );
80
+
81
+ const hasValueFor = React.useCallback(
82
+ (name: string) => Boolean(values[name]?.trim()) || secretExists(name),
83
+ [values, secretExists],
84
+ );
85
+
86
+ const setValue = React.useCallback(
87
+ (name: string, value: string) =>
88
+ setValues((prev) => ({ ...prev, [name]: value })),
89
+ [],
90
+ );
91
+
92
+ const reset = React.useCallback(() => setValues({}), []);
93
+
94
+ return {
95
+ fields,
96
+ values,
97
+ setValue,
98
+ secretExists,
99
+ hasValueFor,
100
+ conflicts: getAcpCredentialConflicts(providerKey, hasValueFor),
101
+ // `=== true` is the intended strict check: only a credential-bearing field
102
+ // (API key, OAuth token, file blob) counts — never a base URL or GCP scalar,
103
+ // which omit `secret` entirely. A field must never set `secret: false` to
104
+ // opt out; omit the flag instead.
105
+ credentialsConfigured: fields.some(
106
+ (field) => field.secret === true && secretExists(field.name),
107
+ ),
108
+ consumesFileCredentials,
109
+ isDirty: fields.some((field) => Boolean(values[field.name]?.trim())),
110
+ save: (options) => saveFilled(values, options),
111
+ reset,
112
+ isSaving,
113
+ };
114
+ }
src/hooks/use-active-agent-profile.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgentKind } from "@openhands/typescript-client";
2
+ import type { AgentProfileSummary } from "#/api/agent-profiles-service/agent-profiles-service.api";
3
+ import { useAgentProfiles } from "#/hooks/query/use-agent-profiles";
4
+
5
+ /**
6
+ * The agent profile the user has activated — the authoritative "current agent"
7
+ * now that Settings → Agent IS the profile library (#1571). `activate` is
8
+ * pointer-only and never writes `settings.agent_settings`, so the active
9
+ * profile — not the global agent settings — is the source of truth for what
10
+ * kind of agent (OpenHands vs ACP) is in effect.
11
+ */
12
+ export function useActiveAgentProfile(): {
13
+ activeProfile: AgentProfileSummary | null;
14
+ isLoading: boolean;
15
+ } {
16
+ const { data, isLoading } = useAgentProfiles();
17
+ const activeId = data?.active_agent_profile_id ?? null;
18
+ const activeProfile =
19
+ data?.profiles?.find((p) => p.id != null && p.id === activeId) ?? null;
20
+ return { activeProfile, isLoading };
21
+ }
22
+
23
+ /**
24
+ * The effective agent kind from the active profile. `undefined` while the
25
+ * profile list is loading or when no profile is active — callers should fall
26
+ * back to `settings.agent_settings.agent_kind` in that window to avoid a flash
27
+ * of the wrong (OpenHands-default) UI.
28
+ */
29
+ export function useActiveAgentKind(): AgentKind | undefined {
30
+ const { activeProfile } = useActiveAgentProfile();
31
+ return activeProfile?.agent_kind;
32
+ }
src/hooks/use-agent-state.test.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useAgentState } from "./use-agent-state";
4
+ import { useActiveConversation } from "./query/use-active-conversation";
5
+ import { useOptionalConversationId } from "./use-conversation-id";
6
+ import { useConversationStateStore } from "#/stores/conversation-state-store";
7
+ import { AgentState } from "#/types/agent-state";
8
+ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
9
+
10
+ const TEST_CONVERSATION_ID = "test-conversation-id";
11
+
12
+ vi.mock("./query/use-active-conversation", () => ({
13
+ useActiveConversation: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("./use-conversation-id", () => ({
17
+ useOptionalConversationId: vi.fn(),
18
+ }));
19
+
20
+ describe("useAgentState", () => {
21
+ const mockUseActiveConversation = vi.mocked(useActiveConversation);
22
+ const mockUseOptionalConversationId = vi.mocked(useOptionalConversationId);
23
+
24
+ beforeEach(() => {
25
+ act(() => {
26
+ useConversationStateStore.getState().reset();
27
+ });
28
+
29
+ mockUseActiveConversation.mockReturnValue({
30
+ data: null,
31
+ } as ReturnType<typeof useActiveConversation>);
32
+
33
+ mockUseOptionalConversationId.mockReturnValue({
34
+ conversationId: TEST_CONVERSATION_ID,
35
+ });
36
+ });
37
+
38
+ it("prefers live websocket execution status over cached conversation status", () => {
39
+ mockUseActiveConversation.mockReturnValue({
40
+ data: {
41
+ execution_status: ExecutionStatus.FINISHED,
42
+ },
43
+ } as ReturnType<typeof useActiveConversation>);
44
+
45
+ act(() => {
46
+ useConversationStateStore
47
+ .getState()
48
+ .setExecutionStatus(TEST_CONVERSATION_ID, ExecutionStatus.RUNNING);
49
+ });
50
+
51
+ const { result } = renderHook(() => useAgentState());
52
+
53
+ expect(result.current.executionStatus).toBe(ExecutionStatus.RUNNING);
54
+ expect(result.current.curAgentState).toBe(AgentState.RUNNING);
55
+ });
56
+
57
+ it("falls back to cached conversation execution status when live state is empty", () => {
58
+ mockUseActiveConversation.mockReturnValue({
59
+ data: {
60
+ execution_status: ExecutionStatus.WAITING_FOR_CONFIRMATION,
61
+ },
62
+ } as ReturnType<typeof useActiveConversation>);
63
+
64
+ const { result } = renderHook(() => useAgentState());
65
+
66
+ expect(result.current.executionStatus).toBe(
67
+ ExecutionStatus.WAITING_FOR_CONFIRMATION,
68
+ );
69
+ expect(result.current.curAgentState).toBe(
70
+ AgentState.AWAITING_USER_CONFIRMATION,
71
+ );
72
+ });
73
+ });
src/hooks/use-agent-state.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
3
+ import { useOptionalConversationId } from "#/hooks/use-conversation-id";
4
+ import { useConversationStateStore } from "#/stores/conversation-state-store";
5
+ import { useConversationStore } from "#/stores/conversation-store";
6
+ import { AgentState } from "#/types/agent-state";
7
+ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
8
+
9
+ /**
10
+ * Maps agent execution status to AgentState
11
+ */
12
+ function mapExecutionStatusToAgentState(
13
+ status: ExecutionStatus | null,
14
+ ): AgentState {
15
+ if (!status) {
16
+ return AgentState.LOADING;
17
+ }
18
+
19
+ switch (status) {
20
+ case ExecutionStatus.IDLE:
21
+ return AgentState.AWAITING_USER_INPUT;
22
+ case ExecutionStatus.RUNNING:
23
+ return AgentState.RUNNING;
24
+ case ExecutionStatus.PAUSED:
25
+ return AgentState.PAUSED;
26
+ case ExecutionStatus.WAITING_FOR_CONFIRMATION:
27
+ return AgentState.AWAITING_USER_CONFIRMATION;
28
+ case ExecutionStatus.FINISHED:
29
+ return AgentState.FINISHED;
30
+ case ExecutionStatus.ERROR:
31
+ return AgentState.ERROR;
32
+ case ExecutionStatus.STUCK:
33
+ return AgentState.ERROR; // Map STUCK to ERROR for now
34
+ default:
35
+ return AgentState.LOADING;
36
+ }
37
+ }
38
+
39
+ export interface UseAgentStateResult {
40
+ curAgentState: AgentState;
41
+ executionStatus?: ExecutionStatus | null;
42
+ }
43
+
44
+ /**
45
+ * Returns the current agent state from conversation execution status.
46
+ *
47
+ * Defaults to the conversation in the current route. Pass `conversationId`
48
+ * to read another conversation's status instead — e.g. a local planner
49
+ * helper conversation, whose run/idle transitions are tracked separately
50
+ * from the main conversation's (see conversation-state-store.ts).
51
+ */
52
+ export function useAgentState(conversationId?: string): UseAgentStateResult {
53
+ const { conversationId: routeConversationId } = useOptionalConversationId();
54
+ const targetConversationId = conversationId ?? routeConversationId;
55
+ const isRouteConversation =
56
+ !conversationId || conversationId === routeConversationId;
57
+
58
+ const liveExecutionStatus = useConversationStateStore((state) =>
59
+ targetConversationId
60
+ ? (state.executionStatusByConversation[targetConversationId] ?? null)
61
+ : null,
62
+ );
63
+ // The REST fallback only ever describes the route's own conversation, so it
64
+ // only applies when no other conversation was explicitly requested.
65
+ const routeConversationExecutionStatus =
66
+ useActiveConversation().data?.execution_status ?? null;
67
+ const fallbackExecutionStatus = isRouteConversation
68
+ ? routeConversationExecutionStatus
69
+ : null;
70
+
71
+ const executionStatus = liveExecutionStatus ?? fallbackExecutionStatus;
72
+ const curAgentState = useMemo(
73
+ () => mapExecutionStatusToAgentState(executionStatus),
74
+ [executionStatus],
75
+ );
76
+
77
+ return { curAgentState, executionStatus };
78
+ }
79
+
80
+ export interface UsePlanningAgentStateResult {
81
+ localPlanningConversationId: string | null;
82
+ curPlanningAgentState: AgentState;
83
+ /** Running or loading. `false` (not "unknown") when there's no planner yet. */
84
+ isPlanningAgentRunning: boolean;
85
+ }
86
+
87
+ /**
88
+ * The local planner helper's own state — read separately from the main
89
+ * conversation's via `useAgentState` since the two run independently.
90
+ */
91
+ export function usePlanningAgentState(): UsePlanningAgentStateResult {
92
+ const localPlanningConversationId = useConversationStore(
93
+ (state) => state.localPlanningConversationId,
94
+ );
95
+ const { curAgentState: curPlanningAgentState } = useAgentState(
96
+ localPlanningConversationId ?? undefined,
97
+ );
98
+ const isPlanningAgentRunning =
99
+ !!localPlanningConversationId &&
100
+ (curPlanningAgentState === AgentState.RUNNING ||
101
+ curPlanningAgentState === AgentState.LOADING);
102
+ return {
103
+ localPlanningConversationId,
104
+ curPlanningAgentState,
105
+ isPlanningAgentRunning,
106
+ };
107
+ }
src/hooks/use-auto-refresh-files-on-edit.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+
4
+ import { useEventStore, type OHEvent } from "#/stores/use-event-store";
5
+ import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter";
6
+
7
+ // `kind` values we treat as a file-mutation observation.
8
+ const FILE_EDIT_OBSERVATION_KINDS = new Set([
9
+ "FileEditorObservation",
10
+ "StrReplaceEditorObservation",
11
+ "PlanningFileEditorObservation",
12
+ ]);
13
+
14
+ // Commands on the str-replace-editor family that don't change anything on
15
+ // disk. We don't want to invalidate caches for those.
16
+ const READ_ONLY_COMMANDS = new Set(["view"]);
17
+
18
+ // Bash observations are how `git commit` / `git push` (and any file edit
19
+ // done through the shell) arrive, so they must refresh the Diff view too.
20
+ // Legacy agent-servers emit `ExecuteBashObservation`; the current SDK
21
+ // emits `TerminalObservation`.
22
+ const BASH_OBSERVATION_KINDS = new Set([
23
+ "ExecuteBashObservation",
24
+ "TerminalObservation",
25
+ ]);
26
+
27
+ function isFileMutationObservation(event: OHEvent): boolean {
28
+ // ObservationEvents have `source: "environment"` and an `observation`
29
+ // field — narrow to that shape without pulling in the whole event union.
30
+ const obs = (event as { observation?: { kind?: string; command?: string } })
31
+ .observation;
32
+ if (!obs || typeof obs.kind !== "string") return false;
33
+ if (!FILE_EDIT_OBSERVATION_KINDS.has(obs.kind)) return false;
34
+ if (obs.command && READ_ONLY_COMMANDS.has(obs.command)) return false;
35
+ return true;
36
+ }
37
+
38
+ // Note: `READ_ONLY_COMMANDS` intentionally doesn't apply here — for bash
39
+ // observations `command` is the whole shell command line, not a
40
+ // file-editor sub-command.
41
+ function isBashObservation(event: OHEvent): boolean {
42
+ const obs = (event as { observation?: { kind?: string } }).observation;
43
+ return (
44
+ !!obs &&
45
+ typeof obs.kind === "string" &&
46
+ BASH_OBSERVATION_KINDS.has(obs.kind)
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Watches the conversation event stream and invalidates the workspace file
52
+ * queries whenever the agent commits a file-editor mutation (create / edit /
53
+ * insert / undo_edit). This keeps the Files tab's list, content view and
54
+ * diff view in sync with what the agent has actually written to disk,
55
+ * without requiring the user to click refresh manually.
56
+ *
57
+ * Bash observations also refresh the git-diff queries (`file_changes` /
58
+ * `file_diff`) — a `git commit` or `git push` changes what the Diff view
59
+ * should display, and shell commands can edit files too. They deliberately
60
+ * do NOT touch the workspace file queries or the workspace mutation
61
+ * counter: bumping the counter reloads canvas iframes, and doing that for
62
+ * every shell command the agent runs would cause constant flicker.
63
+ * Invalidation only refetches actively-mounted queries, so the cost is
64
+ * limited to when the Files tab is open.
65
+ *
66
+ * Mount this hook inside any component that should drive auto-refresh —
67
+ * the Files tab is the obvious caller. Multiple mounts are safe because
68
+ * React Query coalesces overlapping invalidations.
69
+ */
70
+ export function useAutoRefreshFilesOnEdit(): void {
71
+ const queryClient = useQueryClient();
72
+ const events = useEventStore((state) => state.events);
73
+ const bumpWorkspaceMutationCounter = useWorkspaceMutationCounter(
74
+ (state) => state.bump,
75
+ );
76
+
77
+ // Track which events we've already reacted to. Two parallel stores:
78
+ //
79
+ // 1. `processedIdsRef` — keys events that *have* an id. The event store
80
+ // re-sorts on insert when out-of-order events arrive (an older event
81
+ // can land *between* two newer ones already in the array), so we
82
+ // cannot use a `slice(processedCount)` trick — it would miss a
83
+ // late-arriving older event because the array length grew but the
84
+ // tail we just diffed didn't contain it. Using a Set of ids is O(n)
85
+ // per render in the worst case but small in practice and immune to
86
+ // reordering. Type matches the event store's own dedup set
87
+ // (`Set<string | number>` in `use-event-store.ts`) so a stray
88
+ // numeric id (legacy server payload, hand-crafted test event, …)
89
+ // can't sneak past.
90
+ //
91
+ // 2. `processedEventsRef` — keys events that have NO id. We cannot put
92
+ // a literal `undefined` into the id Set: that would make the second,
93
+ // third, … id-less arrival collide on the same `undefined` key and
94
+ // silently skip them. We also cannot just skip dedup for id-less
95
+ // events: the events array is rebuilt on every store mutation but
96
+ // its element references are stable, so the same id-less event
97
+ // appears in the array forever and would re-bump the workspace
98
+ // mutation counter on every subsequent re-render. Keying by object
99
+ // reference (via a WeakSet) gives us "process each id-less event
100
+ // exactly once" — durable, free, no held-onto memory after the
101
+ // store clears.
102
+ const processedIdsRef = useRef<Set<string | number>>(new Set());
103
+ const processedEventsRef = useRef<WeakSet<OHEvent>>(new WeakSet());
104
+
105
+ useEffect(() => {
106
+ let hasNewFileEdits = false;
107
+ let hasNewBashCommands = false;
108
+ for (const event of events) {
109
+ const id: string | number | undefined =
110
+ "id" in event ? event.id : undefined;
111
+ const alreadyProcessed =
112
+ id !== undefined
113
+ ? processedIdsRef.current.has(id)
114
+ : processedEventsRef.current.has(event);
115
+ // Inverted predicate so we avoid `continue` (banned by repo lint).
116
+ if (!alreadyProcessed) {
117
+ if (id !== undefined) {
118
+ processedIdsRef.current.add(id);
119
+ } else {
120
+ processedEventsRef.current.add(event);
121
+ }
122
+ if (isFileMutationObservation(event)) hasNewFileEdits = true;
123
+ else if (isBashObservation(event)) hasNewBashCommands = true;
124
+ }
125
+ }
126
+
127
+ if (!hasNewFileEdits && !hasNewBashCommands) return;
128
+
129
+ // Editor and bash mutations both change what the git Diff view shows —
130
+ // including the per-file diff content of an already-expanded file.
131
+ // The commits list refreshes too (a `git commit` arrives as a bash
132
+ // observation); per-commit queries (`commit_changes` /
133
+ // `commit_file_diff`) are sha-addressed and immutable, so they are
134
+ // deliberately NOT invalidated.
135
+ queryClient.invalidateQueries({ queryKey: ["file_changes"] });
136
+ queryClient.invalidateQueries({ queryKey: ["file_diff"] });
137
+ queryClient.invalidateQueries({ queryKey: ["git_commits"] });
138
+
139
+ if (hasNewFileEdits) {
140
+ queryClient.invalidateQueries({ queryKey: ["workspace-files"] });
141
+ queryClient.invalidateQueries({ queryKey: ["workspace-file-content"] });
142
+ // Force iframes / <img> tags pointing at the static workspace
143
+ // fileserver to re-fetch. Without this they happily keep showing the
144
+ // stale (browser-cached) bytes even after the agent has rewritten the
145
+ // file on disk — e.g. tweaking style.css would silently have no
146
+ // visible effect on the rendered index.html until the user reloaded
147
+ // the whole canvas.
148
+ bumpWorkspaceMutationCounter();
149
+ }
150
+ }, [events, queryClient, bumpWorkspaceMutationCounter]);
151
+ }
src/hooks/use-await-context-compaction.test.ts ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import useMetricsStore from "#/stores/metrics-store";
4
+ import { useEventStore } from "#/stores/use-event-store";
5
+ import type { OpenHandsEvent } from "#/types/agent-server/core";
6
+ import {
7
+ buildContextCompactionResult,
8
+ useAwaitContextCompaction,
9
+ } from "./use-await-context-compaction";
10
+
11
+ function condensationEvent(id: string): OpenHandsEvent {
12
+ return {
13
+ id,
14
+ timestamp: new Date().toISOString(),
15
+ source: "environment",
16
+ kind: "Condensation",
17
+ forgotten_event_ids: ["a", "b"],
18
+ } as OpenHandsEvent;
19
+ }
20
+
21
+ describe("buildContextCompactionResult", () => {
22
+ it("computes tokens saved without going negative", () => {
23
+ expect(buildContextCompactionResult(1000, 400, "compacted")).toEqual({
24
+ beforeToken: 1000,
25
+ afterToken: 400,
26
+ savedToken: 600,
27
+ outcome: "compacted",
28
+ });
29
+ expect(buildContextCompactionResult(400, 500, "no_change").savedToken).toBe(
30
+ 0,
31
+ );
32
+ });
33
+ });
34
+
35
+ describe("useAwaitContextCompaction", () => {
36
+ beforeEach(() => {
37
+ vi.useFakeTimers();
38
+ useEventStore.getState().clearEvents();
39
+ useMetricsStore.getState().setMetrics({
40
+ cost: null,
41
+ max_budget_per_task: null,
42
+ usage: {
43
+ prompt_tokens: 0,
44
+ completion_tokens: 0,
45
+ cache_read_tokens: 0,
46
+ cache_write_tokens: 0,
47
+ context_window: 100_000,
48
+ per_turn_token: 10_000,
49
+ },
50
+ });
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.useRealTimers();
55
+ useEventStore.getState().clearEvents();
56
+ });
57
+
58
+ it("reports tokens freed once condensation lands and metrics drop", () => {
59
+ const onComplete = vi.fn();
60
+
61
+ renderHook(() =>
62
+ useAwaitContextCompaction({
63
+ beforeToken: 10_000,
64
+ onComplete,
65
+ }),
66
+ );
67
+
68
+ act(() => {
69
+ useEventStore.getState().addEvent(condensationEvent("cond-1"));
70
+ useMetricsStore.getState().setMetrics({
71
+ cost: null,
72
+ max_budget_per_task: null,
73
+ usage: {
74
+ prompt_tokens: 0,
75
+ completion_tokens: 0,
76
+ cache_read_tokens: 0,
77
+ cache_write_tokens: 0,
78
+ context_window: 100_000,
79
+ per_turn_token: 4_000,
80
+ },
81
+ });
82
+ });
83
+
84
+ expect(onComplete).toHaveBeenCalledWith({
85
+ beforeToken: 10_000,
86
+ afterToken: 4_000,
87
+ savedToken: 6_000,
88
+ outcome: "compacted",
89
+ });
90
+ });
91
+
92
+ it("finishes after the settle window when metrics do not drop", () => {
93
+ const onComplete = vi.fn();
94
+
95
+ renderHook(() =>
96
+ useAwaitContextCompaction({
97
+ beforeToken: 10_000,
98
+ onComplete,
99
+ }),
100
+ );
101
+
102
+ act(() => {
103
+ useEventStore.getState().addEvent(condensationEvent("cond-2"));
104
+ });
105
+ expect(onComplete).not.toHaveBeenCalled();
106
+
107
+ act(() => {
108
+ vi.advanceTimersByTime(2_500);
109
+ });
110
+
111
+ expect(onComplete).toHaveBeenCalledWith({
112
+ beforeToken: 10_000,
113
+ afterToken: 10_000,
114
+ savedToken: 0,
115
+ outcome: "no_change",
116
+ });
117
+ });
118
+
119
+ it("reports timeout when no condensation event arrives in time", () => {
120
+ const onComplete = vi.fn();
121
+
122
+ renderHook(() =>
123
+ useAwaitContextCompaction({
124
+ beforeToken: 10_000,
125
+ onComplete,
126
+ }),
127
+ );
128
+
129
+ act(() => {
130
+ vi.advanceTimersByTime(90_000);
131
+ });
132
+
133
+ expect(onComplete).toHaveBeenCalledWith({
134
+ beforeToken: 10_000,
135
+ afterToken: 10_000,
136
+ savedToken: 0,
137
+ outcome: "timeout",
138
+ });
139
+ });
140
+
141
+ it("detects a condensation that landed before the post-ack effect started", () => {
142
+ // The condense POST can return only after the server already emitted its
143
+ // Condensation event. The pre-request baseline (captured before the POST
144
+ // fired) must not contain it, so it still counts as new.
145
+ const baseline = new Set(useEventStore.getState().eventIds);
146
+ act(() => {
147
+ useEventStore.getState().addEvent(condensationEvent("cond-early"));
148
+ useMetricsStore.getState().setMetrics({
149
+ cost: null,
150
+ max_budget_per_task: null,
151
+ usage: {
152
+ prompt_tokens: 0,
153
+ completion_tokens: 0,
154
+ cache_read_tokens: 0,
155
+ cache_write_tokens: 0,
156
+ context_window: 100_000,
157
+ per_turn_token: 4_000,
158
+ },
159
+ });
160
+ });
161
+
162
+ const onComplete = vi.fn();
163
+ renderHook(() =>
164
+ useAwaitContextCompaction({
165
+ beforeToken: 10_000,
166
+ baselineEventIds: baseline,
167
+ onComplete,
168
+ }),
169
+ );
170
+
171
+ expect(onComplete).toHaveBeenCalledWith({
172
+ beforeToken: 10_000,
173
+ afterToken: 4_000,
174
+ savedToken: 6_000,
175
+ outcome: "compacted",
176
+ });
177
+ });
178
+ });
src/hooks/use-chat-input-model-state.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
2
+ import { useSettings } from "#/hooks/query/use-settings";
3
+ import {
4
+ type AcpModelContext,
5
+ useAcpModelContext,
6
+ } from "#/hooks/use-acp-model-context";
7
+ import { useActiveBackend } from "#/contexts/active-backend-context";
8
+ import { useCanManageOrgProfiles } from "#/hooks/use-can-manage-org-profiles";
9
+ import { useActiveAcpProfileDetail } from "#/hooks/query/use-active-acp-profile-detail";
10
+ import { useOptionalConversationId } from "#/hooks/use-conversation-id";
11
+ import {
12
+ getAcpPreferredDefaultModel,
13
+ getAcpProvider,
14
+ labelForAcpModel,
15
+ resolveEffectiveAcpModel,
16
+ type ACPModelOption,
17
+ } from "#/constants/acp-providers";
18
+
19
+ export interface ChatInputModelState {
20
+ isAcpContext: boolean;
21
+ displayModel: string | null;
22
+ currentModelId: string | null;
23
+ availableAcpModels: ACPModelOption[];
24
+ showAcpPicker: boolean;
25
+ switchConversationId: string | null;
26
+ destinationPath: AcpModelContext["destinationPath"];
27
+ destinationLabel: string;
28
+ }
29
+
30
+ export function useChatInputModelState(): ChatInputModelState {
31
+ const { data: conversation } = useActiveConversation();
32
+ const { data: settings } = useSettings();
33
+ const { conversationId } = useOptionalConversationId();
34
+ const { backend } = useActiveBackend();
35
+ const canManageOrgProfiles = useCanManageOrgProfiles();
36
+ // The active ACP AgentProfile's own fields are the conversation launch
37
+ // source (activation never writes agent_settings, so the global settings
38
+ // may describe a different provider). Null in a conversation, while
39
+ // loading, when the active profile isn't ACP, or on legacy backends
40
+ // without the profiles surface — settings are the fallback in that window.
41
+ const activeAcpProfile = useActiveAcpProfileDetail();
42
+ const {
43
+ isActiveAcpConversation,
44
+ isHomeAcp,
45
+ isAcpContext,
46
+ destinationPath,
47
+ destinationLabel,
48
+ } = useAcpModelContext();
49
+
50
+ const settingsAcpServerKey =
51
+ typeof settings?.agent_settings?.acp_server === "string"
52
+ ? settings.agent_settings.acp_server
53
+ : null;
54
+ const acpServerKey = isActiveAcpConversation
55
+ ? conversation?.acp_server
56
+ : isHomeAcp
57
+ ? (activeAcpProfile?.acp_server ?? settingsAcpServerKey)
58
+ : null;
59
+ const acpProvider = isAcpContext ? getAcpProvider(acpServerKey) : undefined;
60
+
61
+ const settingsAcpModel =
62
+ typeof settings?.agent_settings?.acp_model === "string"
63
+ ? settings.agent_settings.acp_model
64
+ : null;
65
+ // Home: read the model from the same source as the server key — mixing the
66
+ // profile's provider with a stale settings model could pair e.g. a codex
67
+ // provider with a claude model.
68
+ const acpConfiguredModel =
69
+ isHomeAcp && activeAcpProfile
70
+ ? activeAcpProfile.acp_model
71
+ : settingsAcpModel;
72
+
73
+ let currentModelId: string | null = null;
74
+ if (isActiveAcpConversation) {
75
+ // ACP conversations store llm_model as the acp_model (persisted at
76
+ // creation time). Use it directly if available; fall back to the
77
+ // settings-configured model or provider default so the chip stays visible.
78
+ currentModelId =
79
+ conversation?.llm_model ??
80
+ resolveEffectiveAcpModel({
81
+ configured: acpConfiguredModel,
82
+ providerDefault: getAcpPreferredDefaultModel(acpServerKey),
83
+ });
84
+ } else if (isHomeAcp) {
85
+ currentModelId = resolveEffectiveAcpModel({
86
+ configured: acpConfiguredModel,
87
+ // Preferred default (Vertex-safe for Gemini) — must match what the
88
+ // start request would substitute for an unconfigured model.
89
+ providerDefault: getAcpPreferredDefaultModel(acpServerKey),
90
+ });
91
+ } else {
92
+ currentModelId = conversation?.llm_model ?? settings?.llm_model ?? null;
93
+ }
94
+
95
+ const displayModel =
96
+ currentModelId && isAcpContext
97
+ ? (labelForAcpModel(acpServerKey, currentModelId) ?? currentModelId)
98
+ : currentModelId;
99
+ const availableAcpModels = acpProvider?.available_models ?? [];
100
+ // A home-page pick persists into the active ACP profile, which on cloud is
101
+ // org-owned — hide the selectable rows from members who'd only get a 403.
102
+ // Conversation-scoped switches (blank or started) stay member-allowed.
103
+ const canPersistHomeAcpModel =
104
+ !isHomeAcp || backend.kind !== "cloud" || canManageOrgProfiles;
105
+ const showAcpPicker =
106
+ isAcpContext && availableAcpModels.length > 0 && canPersistHomeAcpModel;
107
+ const switchConversationId = isActiveAcpConversation
108
+ ? (conversationId ?? null)
109
+ : null;
110
+
111
+ return {
112
+ isAcpContext,
113
+ displayModel,
114
+ currentModelId,
115
+ availableAcpModels,
116
+ showAcpPicker,
117
+ switchConversationId,
118
+ destinationPath,
119
+ destinationLabel,
120
+ };
121
+ }
src/hooks/use-conversation-overview-git-diff-stats.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import { useQueries } from "@tanstack/react-query";
3
+ import AgentServerGitService from "#/api/git-service/agent-server-git-service.api";
4
+ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
5
+ import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
6
+ import { useConversationId } from "#/hooks/use-conversation-id";
7
+ import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
8
+ import { getGitPath } from "#/utils/get-git-path";
9
+ import {
10
+ countGitChangeDiffStats,
11
+ sumGitDiffLineStats,
12
+ } from "#/utils/git-diff-stats";
13
+
14
+ export function useConversationOverviewGitDiffStats() {
15
+ const { conversationId } = useConversationId();
16
+ const { data: conversation } = useActiveConversation();
17
+ const runtimeIsReady = useRuntimeIsReady();
18
+ const {
19
+ data: gitChanges,
20
+ isLoading: isLoadingChanges,
21
+ isSuccess: isChangesSuccess,
22
+ isError: isChangesError,
23
+ } = useUnifiedGetGitChanges();
24
+
25
+ const conversationUrl = conversation?.conversation_url;
26
+ const sessionApiKey = conversation?.session_api_key;
27
+ const gitPath = useMemo(
28
+ () =>
29
+ getGitPath(
30
+ conversation?.selected_repository,
31
+ conversation?.workspace?.working_dir?.trim(),
32
+ ),
33
+ [conversation?.selected_repository, conversation?.workspace?.working_dir],
34
+ );
35
+
36
+ const diffCandidates = useMemo(
37
+ () => (gitChanges ?? []).filter((change) => change.status !== "D"),
38
+ [gitChanges],
39
+ );
40
+
41
+ const diffQueries = useQueries({
42
+ queries: diffCandidates.map((change) => ({
43
+ queryKey: [
44
+ "conversation_overview_file_diff",
45
+ conversationId,
46
+ conversationUrl,
47
+ sessionApiKey,
48
+ gitPath,
49
+ change.path,
50
+ ],
51
+ queryFn: async () =>
52
+ AgentServerGitService.getGitChangeDiff(
53
+ conversationId!,
54
+ conversationUrl,
55
+ sessionApiKey,
56
+ `${gitPath}/${change.path}`,
57
+ ),
58
+ enabled:
59
+ runtimeIsReady && !!conversationId && isChangesSuccess && !!change.path,
60
+ staleTime: 1000 * 60 * 5,
61
+ meta: {
62
+ disableToast: true,
63
+ },
64
+ })),
65
+ });
66
+
67
+ const isLoadingDiffs = diffQueries.some((query) => query.isLoading);
68
+ const totals = sumGitDiffLineStats(
69
+ diffQueries.flatMap((query) =>
70
+ query.data ? [countGitChangeDiffStats(query.data)] : [],
71
+ ),
72
+ );
73
+
74
+ return {
75
+ additions: totals.additions,
76
+ deletions: totals.deletions,
77
+ changeCount: gitChanges?.length ?? 0,
78
+ isLoading: isLoadingChanges || isLoadingDiffs,
79
+ isError: isChangesError,
80
+ };
81
+ }
src/hooks/use-debounce.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebounce<T>(value: T, delay: number): T {
4
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
5
+
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delay]);
10
+
11
+ return debouncedValue;
12
+ }
src/hooks/use-device-flow.ts ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import {
3
+ startDeviceFlow,
4
+ pollForToken,
5
+ DeviceFlowError,
6
+ type DeviceAuthorizationResponse,
7
+ } from "#/api/device-flow-client";
8
+ import {
9
+ trackCloudDeviceAuthorizationStarted,
10
+ trackCloudDeviceAuthorizationSucceeded,
11
+ type CloudConnectionSource,
12
+ } from "#/services/cloud-funnel-analytics";
13
+
14
+ export type DeviceFlowStatus =
15
+ | "idle"
16
+ | "starting"
17
+ | "awaiting_authorization"
18
+ | "success"
19
+ | "error";
20
+
21
+ export interface DeviceFlowState {
22
+ status: DeviceFlowStatus;
23
+ /** The verification URL to show/open for the user */
24
+ verificationUrl: string | null;
25
+ /** User code to display as fallback */
26
+ userCode: string | null;
27
+ /** The resulting API key on success */
28
+ apiKey: string | null;
29
+ /** Error message if status is "error" */
30
+ error: string | null;
31
+ /** Error code for programmatic handling */
32
+ errorCode: string | null;
33
+ }
34
+
35
+ export interface UseDeviceFlowReturn extends DeviceFlowState {
36
+ /** Start the device flow authentication */
37
+ start: StartDeviceFlow;
38
+ /** Cancel an in-progress flow */
39
+ cancel: () => void;
40
+ /** Reset state back to idle */
41
+ reset: () => void;
42
+ }
43
+
44
+ type StartDeviceFlow = (
45
+ host: string,
46
+ analyticsSource?: CloudConnectionSource,
47
+ ) => void;
48
+
49
+ const initialState: DeviceFlowState = {
50
+ status: "idle",
51
+ verificationUrl: null,
52
+ userCode: null,
53
+ apiKey: null,
54
+ error: null,
55
+ errorCode: null,
56
+ };
57
+
58
+ /**
59
+ * React hook for managing OAuth 2.0 Device Flow authentication.
60
+ *
61
+ * Usage:
62
+ * ```tsx
63
+ * const { status, verificationUrl, apiKey, error, start, cancel, reset } = useDeviceFlow();
64
+ *
65
+ * // Start auth
66
+ * start("https://app.all-hands.dev");
67
+ *
68
+ * // Open browser when awaiting
69
+ * if (status === "awaiting_authorization" && verificationUrl) {
70
+ * window.open(verificationUrl, "_blank");
71
+ * }
72
+ *
73
+ * // Use API key on success
74
+ * if (status === "success" && apiKey) {
75
+ * setApiKeyField(apiKey);
76
+ * }
77
+ * ```
78
+ */
79
+ export function useDeviceFlow(): UseDeviceFlowReturn {
80
+ const [state, setState] = React.useState<DeviceFlowState>(initialState);
81
+ const abortControllerRef = React.useRef<AbortController | null>(null);
82
+
83
+ const start: StartDeviceFlow = React.useCallback((host, analyticsSource) => {
84
+ // Cancel any existing flow
85
+ abortControllerRef.current?.abort();
86
+ const abortController = new AbortController();
87
+ abortControllerRef.current = abortController;
88
+
89
+ setState({
90
+ ...initialState,
91
+ status: "starting",
92
+ });
93
+
94
+ (async () => {
95
+ let authResponse: DeviceAuthorizationResponse;
96
+
97
+ try {
98
+ authResponse = await startDeviceFlow(host);
99
+ } catch (error) {
100
+ if (abortController.signal.aborted) return;
101
+
102
+ const message =
103
+ error instanceof Error
104
+ ? error.message
105
+ : "Failed to start device flow";
106
+ const code = error instanceof DeviceFlowError ? error.code : undefined;
107
+
108
+ setState({
109
+ ...initialState,
110
+ status: "error",
111
+ error: message,
112
+ errorCode: code ?? null,
113
+ });
114
+ return;
115
+ }
116
+
117
+ if (abortController.signal.aborted) return;
118
+
119
+ trackCloudDeviceAuthorizationStarted(host, analyticsSource);
120
+
121
+ setState({
122
+ ...initialState,
123
+ status: "awaiting_authorization",
124
+ verificationUrl: authResponse.verification_uri_complete,
125
+ userCode: authResponse.user_code,
126
+ });
127
+
128
+ try {
129
+ const tokenResponse = await pollForToken(
130
+ host,
131
+ authResponse.device_code,
132
+ {
133
+ interval: authResponse.interval,
134
+ signal: abortController.signal,
135
+ },
136
+ );
137
+
138
+ if (abortController.signal.aborted) return;
139
+
140
+ trackCloudDeviceAuthorizationSucceeded(host, analyticsSource);
141
+
142
+ setState({
143
+ ...initialState,
144
+ status: "success",
145
+ apiKey: tokenResponse.access_token,
146
+ });
147
+ } catch (error) {
148
+ // Early return if component unmounted or user cancelled
149
+ if (abortController.signal.aborted) return;
150
+
151
+ // Defensive: handle cancellation errors that may slip through
152
+ // (currently pollForToken only throws "cancelled" when signal.aborted,
153
+ // which is caught above, but this guards against future changes)
154
+ const isCancel =
155
+ error instanceof DeviceFlowError && error.code === "cancelled";
156
+ if (isCancel) {
157
+ setState(initialState);
158
+ return;
159
+ }
160
+
161
+ const message =
162
+ error instanceof Error ? error.message : "Authorization failed";
163
+ const code = error instanceof DeviceFlowError ? error.code : undefined;
164
+
165
+ setState({
166
+ ...initialState,
167
+ status: "error",
168
+ error: message,
169
+ errorCode: code ?? null,
170
+ });
171
+ }
172
+ })();
173
+ }, []);
174
+
175
+ const cancel = React.useCallback(() => {
176
+ abortControllerRef.current?.abort();
177
+ abortControllerRef.current = null;
178
+ setState(initialState);
179
+ }, []);
180
+
181
+ const reset = React.useCallback(() => {
182
+ abortControllerRef.current?.abort();
183
+ abortControllerRef.current = null;
184
+ setState(initialState);
185
+ }, []);
186
+
187
+ // Cleanup on unmount
188
+ React.useEffect(() => {
189
+ return () => {
190
+ abortControllerRef.current?.abort();
191
+ };
192
+ }, []);
193
+
194
+ return {
195
+ ...state,
196
+ start,
197
+ cancel,
198
+ reset,
199
+ };
200
+ }
src/hooks/use-ensure-active-profile.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { useActiveBackend } from "#/contexts/active-backend-context";
3
+ import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
4
+ import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile";
5
+
6
+ /**
7
+ * Local-mode UX policy: keep an LLM profile active whenever at least one
8
+ * exists, so the agent always has a usable LLM without a manual "activate"
9
+ * step. This lives in the client on purpose — the agent-server is a neutral
10
+ * API that returns `active_profile` as stored (possibly pointing at a deleted
11
+ * profile); other consumers (e.g. SaaS) own the LLM differently.
12
+ *
13
+ * When profiles exist but none is the active one — never activated, or the
14
+ * active profile was deleted — it activates the first profile that has an API
15
+ * key (falling back to the first profile). With zero profiles it does nothing:
16
+ * that genuine "no LLM" state is surfaced by the gate/banner.
17
+ */
18
+ export function useEnsureActiveProfile(): void {
19
+ const { backend } = useActiveBackend();
20
+ const isLocal = backend.kind === "local";
21
+ const { data: profilesData } = useLlmProfiles();
22
+ const { mutate: activate, isPending } = useActivateLlmProfile();
23
+
24
+ // Remember the last profile we tried to activate so we don't re-fire while
25
+ // the mutation + refetch settle, or hammer a profile whose activation fails.
26
+ const attemptedRef = useRef<string | null>(null);
27
+
28
+ // A backend switch is a clean slate for the above guard.
29
+ useEffect(() => {
30
+ attemptedRef.current = null;
31
+ }, [backend.id]);
32
+
33
+ useEffect(() => {
34
+ if (!isLocal || isPending || !profilesData) return;
35
+
36
+ const { profiles, active_profile: activeProfile } = profilesData;
37
+ const activeValid =
38
+ activeProfile != null && profiles.some((p) => p.name === activeProfile);
39
+
40
+ if (profiles.length === 0 || activeValid) {
41
+ attemptedRef.current = null;
42
+ return;
43
+ }
44
+
45
+ // Prefer a profile with a key so the result is immediately usable.
46
+ const target = profiles.find((p) => p.api_key_set) ?? profiles[0];
47
+ if (attemptedRef.current === target.name) return;
48
+ attemptedRef.current = target.name;
49
+ activate(target.name);
50
+ }, [isLocal, profilesData, isPending, activate]);
51
+ }
src/hooks/use-handle-plan-click.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, type MouseEvent } from "react";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useTranslation } from "react-i18next";
4
+ import { I18nKey } from "#/i18n/declaration";
5
+ import { useConversationStore } from "#/stores/conversation-store";
6
+ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
7
+ import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
8
+ import {
9
+ displayErrorToast,
10
+ displaySuccessToast,
11
+ } from "#/utils/custom-toast-handlers";
12
+ import {
13
+ getConversationState,
14
+ setConversationState,
15
+ } from "#/utils/conversation-local-storage";
16
+ import { useActiveBackend } from "#/contexts/active-backend-context";
17
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
18
+ import { getStoredConversationMetadata } from "#/api/conversation-metadata-store";
19
+ import {
20
+ CONVERSATION_QUERY_KEYS,
21
+ LOCAL_PLANNER_MUTATION_KEYS,
22
+ } from "#/hooks/query/query-keys";
23
+ import { useSubConversations } from "#/hooks/query/use-sub-conversations";
24
+ import { findPlannerConversationId } from "#/utils/plan-file";
25
+ import { invalidateConversationQueries } from "#/hooks/mutation/conversation-mutation-utils";
26
+
27
+ function useCreateLocalPlanningConversationMutation(options: {
28
+ onCreated: (planningConversationId: string) => void;
29
+ onInitialized: () => void;
30
+ onFailed: () => void;
31
+ }) {
32
+ const queryClient = useQueryClient();
33
+
34
+ return useMutation({
35
+ mutationKey: LOCAL_PLANNER_MUTATION_KEYS.create,
36
+ mutationFn: (variables: {
37
+ parentConversationId: string;
38
+ initialMessage?: string;
39
+ }) =>
40
+ AgentServerConversationService.createLocalPlanningConversation(
41
+ variables.parentConversationId,
42
+ variables.initialMessage,
43
+ ),
44
+ onSuccess: (planningConversation, variables) => {
45
+ options.onCreated(planningConversation.id);
46
+ invalidateConversationQueries(
47
+ queryClient,
48
+ variables.parentConversationId,
49
+ );
50
+ queryClient.invalidateQueries({
51
+ queryKey: CONVERSATION_QUERY_KEYS.subConversations,
52
+ });
53
+ options.onInitialized();
54
+ },
55
+ onError: options.onFailed,
56
+ });
57
+ }
58
+
59
+ function restorePlanningConversationIds(options: {
60
+ conversationId: string;
61
+ serverPlanningConversationId: string | null;
62
+ subConversationTaskId: string | null;
63
+ localPlanningConversationId: string | null;
64
+ setSubConversationTaskId: (taskId: string | null) => void;
65
+ setLocalPlanningConversationId: (conversationId: string | null) => void;
66
+ }) {
67
+ const storedState = getConversationState(options.conversationId);
68
+ if (storedState.subConversationTaskId && !options.subConversationTaskId) {
69
+ options.setSubConversationTaskId(storedState.subConversationTaskId);
70
+ }
71
+
72
+ // Server first: `sub_conversation_ids` is derived by the agent-server from
73
+ // the planner's `parent_conversation_id`, so it survives cleared site data
74
+ // and follows the user to another browser. The localStorage hint is only the
75
+ // fallback for agent-servers older than 1.37.1, which drop the parent link.
76
+ const restoredId =
77
+ options.serverPlanningConversationId ??
78
+ getStoredConversationMetadata(options.conversationId)
79
+ ?.local_planning_conversation_id ??
80
+ null;
81
+
82
+ if (restoredId && restoredId !== options.localPlanningConversationId) {
83
+ options.setLocalPlanningConversationId(restoredId);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Custom hook that encapsulates the logic for handling plan creation.
89
+ * Returns a function that can be called to create a plan conversation and
90
+ * the pending state of the conversation creation.
91
+ *
92
+ * @returns An object containing handlePlanClick function and isCreatingConversation boolean
93
+ */
94
+ export const useHandlePlanClick = () => {
95
+ const { t } = useTranslation("openhands");
96
+ const { backend } = useActiveBackend();
97
+ const {
98
+ setConversationMode,
99
+ setSubConversationTaskId,
100
+ subConversationTaskId,
101
+ setLocalPlanningConversationId,
102
+ localPlanningConversationId,
103
+ } = useConversationStore();
104
+ const { data: conversation } = useActiveConversation();
105
+ const { mutate: createConversation, isPending: isCreatingCloudConversation } =
106
+ useCreateConversation();
107
+ const {
108
+ mutate: createLocalPlanningConversation,
109
+ isPending: isCreatingLocalPlanningConversation,
110
+ } = useCreateLocalPlanningConversationMutation({
111
+ onCreated: setLocalPlanningConversationId,
112
+ onInitialized: () => {
113
+ displaySuccessToast(
114
+ t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED),
115
+ );
116
+ },
117
+ // handlePlanClick sets conversationMode("plan") before this mutation
118
+ // starts. On failure, back out of that mode instead of stranding the
119
+ // user in plan mode with no planner to talk to.
120
+ onFailed: () => {
121
+ setConversationMode("code");
122
+ displayErrorToast(t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION));
123
+ },
124
+ });
125
+
126
+ // On local backends the agent-server reports the planner helper back on the
127
+ // parent's `sub_conversation_ids` (it was created with
128
+ // `parent_conversation_id`), so that is the authoritative handle. Cloud
129
+ // sub-conversations are driven by their own task/socket plumbing.
130
+ const isLocalBackend = backend.kind !== "cloud";
131
+ const { data: rawSubConversations } = useSubConversations(
132
+ isLocalBackend ? conversation?.sub_conversation_ids : undefined,
133
+ );
134
+
135
+ const serverPlanningConversationId = isLocalBackend
136
+ ? findPlannerConversationId(rawSubConversations, conversation?.id)
137
+ : null;
138
+
139
+ // Restore planning conversation ids on conversation load. This handles page
140
+ // refreshes while cloud or local planning conversation creation is in
141
+ // progress, and recovers the local planner after browser storage is lost.
142
+ useEffect(() => {
143
+ if (!conversation?.id) return;
144
+
145
+ restorePlanningConversationIds({
146
+ conversationId: conversation.id,
147
+ serverPlanningConversationId,
148
+ subConversationTaskId,
149
+ localPlanningConversationId,
150
+ setSubConversationTaskId,
151
+ setLocalPlanningConversationId,
152
+ });
153
+ }, [
154
+ conversation?.id,
155
+ serverPlanningConversationId,
156
+ localPlanningConversationId,
157
+ setLocalPlanningConversationId,
158
+ subConversationTaskId,
159
+ setSubConversationTaskId,
160
+ ]);
161
+
162
+ const hasCloudPlanner = !!(
163
+ (conversation?.sub_conversation_ids &&
164
+ conversation.sub_conversation_ids.length > 0) ||
165
+ subConversationTaskId
166
+ );
167
+ // Whether a planner helper already exists for this conversation — callers
168
+ // (e.g. the `/plan <task>` interceptor) use this to decide whether they can
169
+ // send a message to the planner immediately, or must wait for creation.
170
+ const hasPlanner = isLocalBackend
171
+ ? !!(localPlanningConversationId || serverPlanningConversationId)
172
+ : hasCloudPlanner;
173
+
174
+ const handlePlanClick = useCallback(
175
+ (
176
+ event?: MouseEvent<HTMLButtonElement> | KeyboardEvent,
177
+ initialMessage?: string,
178
+ ) => {
179
+ event?.preventDefault();
180
+ event?.stopPropagation();
181
+
182
+ setConversationMode("plan");
183
+
184
+ if (backend.kind !== "cloud") {
185
+ // Guard on the server-reported helper, the store, and the mutation's
186
+ // own in-flight state — the last one stops two rapid invocations
187
+ // (e.g. a double-click) from both passing before either updates and
188
+ // creating two planners for the same parent.
189
+ if (
190
+ !conversation?.id ||
191
+ localPlanningConversationId ||
192
+ serverPlanningConversationId ||
193
+ isCreatingLocalPlanningConversation
194
+ ) {
195
+ return;
196
+ }
197
+ createLocalPlanningConversation({
198
+ parentConversationId: conversation.id,
199
+ initialMessage,
200
+ });
201
+ return;
202
+ }
203
+
204
+ if (hasCloudPlanner || !conversation?.id) {
205
+ return;
206
+ }
207
+
208
+ createConversation(
209
+ {
210
+ parentConversationId: conversation.id,
211
+ agentType: "plan",
212
+ entryPoint: "plan_sub_conversation",
213
+ ...(initialMessage ? { query: initialMessage } : {}),
214
+ },
215
+ {
216
+ onSuccess: (data) => {
217
+ displaySuccessToast(
218
+ t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED),
219
+ );
220
+ if (data.task_id) {
221
+ setSubConversationTaskId(data.task_id);
222
+ setConversationState(conversation.id, {
223
+ subConversationTaskId: data.task_id,
224
+ });
225
+ }
226
+ },
227
+ },
228
+ );
229
+ },
230
+ [
231
+ backend.kind,
232
+ conversation,
233
+ createConversation,
234
+ createLocalPlanningConversation,
235
+ hasCloudPlanner,
236
+ isCreatingLocalPlanningConversation,
237
+ localPlanningConversationId,
238
+ serverPlanningConversationId,
239
+ setConversationMode,
240
+ setSubConversationTaskId,
241
+ t,
242
+ ],
243
+ );
244
+
245
+ return {
246
+ handlePlanClick,
247
+ hasPlanner,
248
+ isCreatingConversation:
249
+ isCreatingCloudConversation || isCreatingLocalPlanningConversation,
250
+ };
251
+ };
src/hooks/use-handle-runtime-active.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
2
+ import { useAgentState } from "#/hooks/use-agent-state";
3
+
4
+ export const useHandleRuntimeActive = () => {
5
+ const { curAgentState } = useAgentState();
6
+
7
+ const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
8
+
9
+ return { runtimeActive };
10
+ };
src/hooks/use-handle-ws-events.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
3
+ import { AgentState } from "#/types/agent-state";
4
+ import { displayErrorToast } from "#/utils/custom-toast-handlers";
5
+ import { useEventStore } from "#/stores/use-event-store";
6
+ import { useSendMessage } from "#/hooks/use-send-message";
7
+ import {
8
+ isAgentErrorEvent,
9
+ isAgentServerEvent,
10
+ } from "#/types/agent-server/type-guards";
11
+
12
+ interface ServerError {
13
+ error: boolean | string;
14
+ message: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ const isServerError = (data: object): data is ServerError => "error" in data;
19
+
20
+ const isTypedErrorEvent = (
21
+ event: object,
22
+ ): event is { type: "error"; message?: unknown } =>
23
+ "type" in event && event.type === "error";
24
+
25
+ export const useHandleWSEvents = () => {
26
+ const { send } = useSendMessage();
27
+ const events = useEventStore((state) => state.events);
28
+
29
+ React.useEffect(() => {
30
+ if (!events.length) {
31
+ return;
32
+ }
33
+ const event = events[events.length - 1];
34
+
35
+ // V1 agent errors are surfaced inline in the chat log (and via the error
36
+ // banner), so don't double-notify with a toast.
37
+ if (isAgentServerEvent(event) && isAgentErrorEvent(event)) {
38
+ return;
39
+ }
40
+
41
+ if (isServerError(event)) {
42
+ if (event.error_code === 401) {
43
+ displayErrorToast("Session expired.");
44
+ return;
45
+ }
46
+
47
+ if (typeof event.error === "string") {
48
+ displayErrorToast(event.error);
49
+ } else {
50
+ displayErrorToast(event.message);
51
+ }
52
+ return;
53
+ }
54
+
55
+ if (isTypedErrorEvent(event)) {
56
+ const message: string = `${event.message ?? ""}`;
57
+ if (message.startsWith("Agent reached maximum")) {
58
+ // We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
59
+ send(generateAgentStateChangeEvent(AgentState.PAUSED));
60
+ }
61
+ }
62
+ }, [events.length]);
63
+ };
src/hooks/use-llm-configured.test.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import SettingsService from "#/api/settings-service/settings-service.api";
6
+ import OptionService from "#/api/option-service/option-service.api";
7
+ import ProfilesService from "#/api/profiles-service/profiles-service.api";
8
+ import {
9
+ setActiveSelection,
10
+ setRegisteredBackends,
11
+ } from "#/api/backend-registry/active-store";
12
+ import type { Backend } from "#/api/backend-registry/types";
13
+ import { ActiveBackendProvider } from "#/contexts/active-backend-context";
14
+ import { DEFAULT_SETTINGS } from "#/services/settings";
15
+ import { useLlmConfigured } from "./use-llm-configured";
16
+
17
+ vi.mock("#/api/settings-service/settings-service.api", () => ({
18
+ default: {
19
+ getSettings: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ vi.mock("#/api/option-service/option-service.api", () => ({
24
+ default: {
25
+ getConfig: vi.fn(),
26
+ },
27
+ }));
28
+
29
+ vi.mock("#/api/profiles-service/profiles-service.api", () => ({
30
+ default: {
31
+ listProfiles: vi.fn(),
32
+ getProfile: vi.fn(),
33
+ },
34
+ }));
35
+
36
+ const localBackend: Backend = {
37
+ id: "test-local",
38
+ name: "Test Local",
39
+ host: "http://localhost:8001",
40
+ apiKey: "test-key",
41
+ kind: "local",
42
+ };
43
+
44
+ function renderLlmConfiguredHook() {
45
+ const queryClient = new QueryClient({
46
+ defaultOptions: {
47
+ queries: { retry: false },
48
+ },
49
+ });
50
+
51
+ const wrapper = ({ children }: { children: ReactNode }) => (
52
+ <QueryClientProvider client={queryClient}>
53
+ <ActiveBackendProvider>{children}</ActiveBackendProvider>
54
+ </QueryClientProvider>
55
+ );
56
+
57
+ return renderHook(() => useLlmConfigured(), { wrapper });
58
+ }
59
+
60
+ describe("useLlmConfigured", () => {
61
+ const mockGetSettings = vi.mocked(SettingsService.getSettings);
62
+ const mockGetConfig = vi.mocked(OptionService.getConfig);
63
+ const mockListProfiles = vi.mocked(ProfilesService.listProfiles);
64
+ const mockGetProfile = vi.mocked(ProfilesService.getProfile);
65
+
66
+ beforeEach(() => {
67
+ setRegisteredBackends([localBackend]);
68
+ setActiveSelection({ backendId: localBackend.id });
69
+
70
+ mockGetSettings.mockResolvedValue({
71
+ ...DEFAULT_SETTINGS,
72
+ llm_api_key_set: false,
73
+ agent_settings: {
74
+ ...(DEFAULT_SETTINGS.agent_settings ?? {}),
75
+ agent_kind: "openhands",
76
+ llm: {
77
+ model: "openai/gpt-5.5",
78
+ auth_type: "subscription",
79
+ subscription_vendor: "openai",
80
+ },
81
+ },
82
+ });
83
+ mockGetConfig.mockResolvedValue({
84
+ feature_flags: { hide_llm_settings: false },
85
+ } as Awaited<ReturnType<typeof OptionService.getConfig>>);
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.clearAllMocks();
90
+ setActiveSelection(null);
91
+ setRegisteredBackends([]);
92
+ });
93
+
94
+ it("treats an active subscription LLM profile without an API key as configured", async () => {
95
+ mockListProfiles.mockResolvedValue({
96
+ active_profile: "gpt-5.5-sub",
97
+ profiles: [
98
+ {
99
+ name: "gpt-5.5-sub",
100
+ model: "gpt-5.5",
101
+ base_url: "https://chatgpt.com/backend-api/codex",
102
+ api_key_set: false,
103
+ },
104
+ ],
105
+ });
106
+ mockGetProfile.mockResolvedValue({
107
+ name: "gpt-5.5-sub",
108
+ api_key_set: false,
109
+ config: {
110
+ model: "openai/gpt-5.5",
111
+ auth_type: "subscription",
112
+ subscription_vendor: "openai",
113
+ },
114
+ });
115
+
116
+ const { result } = renderLlmConfiguredHook();
117
+
118
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
119
+
120
+ expect(result.current.isConfigured).toBe(true);
121
+ expect(mockGetProfile).toHaveBeenCalledWith("gpt-5.5-sub");
122
+ });
123
+
124
+ it("uses the api_key_set fast path for an API-key profile without fetching profile detail", async () => {
125
+ mockListProfiles.mockResolvedValue({
126
+ active_profile: "gpt-5.5",
127
+ profiles: [
128
+ {
129
+ name: "gpt-5.5",
130
+ model: "gpt-5.5",
131
+ base_url: "https://api.openai.com/v1",
132
+ api_key_set: true,
133
+ },
134
+ ],
135
+ });
136
+
137
+ const { result } = renderLlmConfiguredHook();
138
+
139
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
140
+
141
+ expect(result.current.isConfigured).toBe(true);
142
+ // The fast path must not fetch active profile detail.
143
+ expect(mockGetProfile).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("treats a no-key, non-subscription local profile as not configured", async () => {
147
+ mockListProfiles.mockResolvedValue({
148
+ active_profile: "gpt-5.5",
149
+ profiles: [
150
+ {
151
+ name: "gpt-5.5",
152
+ model: "gpt-5.5",
153
+ base_url: "https://api.openai.com/v1",
154
+ api_key_set: false,
155
+ },
156
+ ],
157
+ });
158
+ mockGetProfile.mockResolvedValue({
159
+ name: "gpt-5.5",
160
+ api_key_set: false,
161
+ config: {
162
+ model: "openai/gpt-5.5",
163
+ // No auth_type: "subscription" — this is a plain key-less profile,
164
+ // not a subscription-authenticated one, so it must be unconfigured.
165
+ },
166
+ });
167
+
168
+ const { result } = renderLlmConfiguredHook();
169
+
170
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
171
+
172
+ expect(result.current.isConfigured).toBe(false);
173
+ // Detail is fetched because the profile is local + active + key-less.
174
+ expect(mockGetProfile).toHaveBeenCalledWith("gpt-5.5");
175
+ });
176
+ });
src/hooks/use-migrate-enabled-skills.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { isNoBackend } from "#/api/backend-registry/active-store";
3
+ import { useActiveBackend } from "#/contexts/active-backend-context";
4
+ import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
5
+ import { useSettings } from "#/hooks/query/use-settings";
6
+ import {
7
+ migrateSkillEnablement,
8
+ toSkillEnablement,
9
+ } from "#/utils/skill-enablement";
10
+
11
+ /**
12
+ * Move a workspace to an explicit `enabled_skills` allow-list, once.
13
+ *
14
+ * It runs at the app root, not on the Customize page: until it has run the
15
+ * resolver falls back to the curated default, which would silently drop
16
+ * catalog skills an existing workspace had switched on. Local only — cloud
17
+ * never reads the field.
18
+ */
19
+ export function useMigrateEnabledSkills(): void {
20
+ const { backend } = useActiveBackend();
21
+ const isLocal = backend.kind === "local" && !isNoBackend(backend);
22
+ const { data: settings, isLoading, isError } = useSettings();
23
+ const { mutate: saveSettings } = useSaveSettings();
24
+
25
+ // One attempt per backend: the save invalidates the settings query, so
26
+ // without this the refetch would re-enter before the write is visible.
27
+ const migratedBackendRef = useRef<string | null>(null);
28
+
29
+ useEffect(() => {
30
+ migratedBackendRef.current = null;
31
+ }, [backend.id]);
32
+
33
+ useEffect(() => {
34
+ if (!isLocal || isLoading || isError || !settings) return;
35
+ if (migratedBackendRef.current === backend.id) return;
36
+
37
+ migratedBackendRef.current = backend.id;
38
+ const migrated = migrateSkillEnablement(toSkillEnablement(settings));
39
+ // Silent on failure: the resolver's fallback keeps the session working and
40
+ // the next app load retries.
41
+ if (migrated) saveSettings(migrated);
42
+ }, [isLocal, isLoading, isError, settings, backend.id, saveSettings]);
43
+ }
src/hooks/use-pinned-home-route.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useLocalStorage } from "@uidotdev/usehooks";
2
+ import { useCallback, useMemo } from "react";
3
+ import {
4
+ getActiveBackend,
5
+ isNoBackend,
6
+ } from "#/api/backend-registry/active-store";
7
+ import { useActiveBackend } from "#/contexts/active-backend-context";
8
+ import {
9
+ automationListPath,
10
+ hasAutomationInterface,
11
+ } from "#/manifests/automation-interface";
12
+
13
+ export const PINNED_HOME_ROUTE_KEY = "oh:pinned-home-route";
14
+
15
+ /** Sidebar Customize entry; also the pin target, so the two cannot drift. */
16
+ export const CUSTOMIZE_PATH = "/customize";
17
+
18
+ /**
19
+ * The pin is stored per backend + org: it may reference a surface that only
20
+ * exists on the backend that set it (e.g. /automations requires that
21
+ * deployment's interface manifest) — a shared key would let one backend
22
+ * redirect `/` on another.
23
+ */
24
+ export function getPinnedHomeRouteKey(
25
+ backendId: string,
26
+ orgId: string | null,
27
+ ): string {
28
+ return `${PINNED_HOME_ROUTE_KEY}:${backendId}:${orgId ?? "-"}`;
29
+ }
30
+
31
+ /**
32
+ * Whether `path` may serve as the home route right now. Shared by the
33
+ * sidebar pin affordance and the `/` loader, so a stored pin that stops
34
+ * resolving (backend switch, manifest absent) is ignored rather than an
35
+ * error. `/` is never pinnable, which makes a redirect loop impossible.
36
+ * Canvas Extensions pages can later add an `/extensions/…` branch here
37
+ * without any storage or loader change.
38
+ */
39
+ export function isPinnableRoute(path: string): boolean {
40
+ if (path === CUSTOMIZE_PATH) return true;
41
+ if (path === automationListPath()) return hasAutomationInterface();
42
+ return false;
43
+ }
44
+
45
+ function sanitizePinnedRoute(value: unknown): string | null {
46
+ if (typeof value !== "string" || !value) return null;
47
+ return isPinnableRoute(value) ? value : null;
48
+ }
49
+
50
+ /**
51
+ * Synchronous pin read for route loaders (no React context). Reads the key
52
+ * `usePinnedHomeRoute` writes; `useLocalStorage` JSON-serializes values, so
53
+ * parse defensively and treat anything unreadable as "no pin". An invalid
54
+ * pin is ignored, not cleared — it may become valid again (e.g. the
55
+ * automations interface returning after a backend switch back).
56
+ */
57
+ export function readPinnedHomeRoute(): string | null {
58
+ const active = getActiveBackend();
59
+ if (isNoBackend(active.backend)) return null;
60
+
61
+ let raw: string | null = null;
62
+ try {
63
+ raw = window.localStorage.getItem(
64
+ getPinnedHomeRouteKey(active.backend.id, active.orgId),
65
+ );
66
+ } catch {
67
+ return null;
68
+ }
69
+ if (raw === null) return null;
70
+
71
+ try {
72
+ return sanitizePinnedRoute(JSON.parse(raw));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** Pin state for the home route: `/` redirects to the pinned sidebar page. */
79
+ export function usePinnedHomeRoute() {
80
+ const active = useActiveBackend();
81
+ const [rawPinnedRoute, setRawPinnedRoute] = useLocalStorage<string | null>(
82
+ getPinnedHomeRouteKey(active.backend.id, active.orgId),
83
+ null,
84
+ );
85
+
86
+ const pinnedRoute = useMemo(
87
+ () => sanitizePinnedRoute(rawPinnedRoute),
88
+ [rawPinnedRoute],
89
+ );
90
+
91
+ const isPinnedRoute = useCallback(
92
+ (path: string) => pinnedRoute === path,
93
+ [pinnedRoute],
94
+ );
95
+
96
+ const togglePinnedRoute = useCallback(
97
+ (path: string) => {
98
+ if (pinnedRoute === path) {
99
+ setRawPinnedRoute(null);
100
+ return;
101
+ }
102
+ if (!isPinnableRoute(path)) return;
103
+ setRawPinnedRoute(path);
104
+ },
105
+ [pinnedRoute, setRawPinnedRoute],
106
+ );
107
+
108
+ return {
109
+ pinnedRoute,
110
+ isPinnedRoute,
111
+ togglePinnedRoute,
112
+ };
113
+ }
src/hooks/use-popover-fixed-placement.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export interface FixedPlacementBox {
4
+ top: number;
5
+ left: number;
6
+ width: number;
7
+ }
8
+
9
+ /**
10
+ * Position a popover with `position: fixed`, anchored below the trigger and
11
+ * clamped within the viewport. Used by the conversation-panel "+ New
12
+ * conversation" menus when they're rendered inside an overflow-hidden
13
+ * sidebar and would otherwise be clipped.
14
+ *
15
+ * Returns the measured `{ top, left, width }` box (or `null` when the
16
+ * popover is closed or fixed placement is disabled). The hook also wires
17
+ * up window resize + capture-phase scroll listeners so the box follows the
18
+ * trigger as the page moves.
19
+ */
20
+ export function usePopoverFixedPlacement(
21
+ triggerRef: React.RefObject<HTMLElement | null>,
22
+ options: { open: boolean; enabled: boolean; targetWidth?: number },
23
+ ): FixedPlacementBox | null {
24
+ const { open, enabled, targetWidth = 16 * 16 } = options;
25
+ const [box, setBox] = React.useState<FixedPlacementBox | null>(null);
26
+
27
+ const measure = React.useCallback(() => {
28
+ const el = triggerRef.current;
29
+ if (!el) return;
30
+ const r = el.getBoundingClientRect();
31
+ const gutter = 8;
32
+ const width = Math.min(targetWidth, window.innerWidth - gutter * 2);
33
+ let left = r.right - width;
34
+ if (left < gutter) left = gutter;
35
+ if (left + width > window.innerWidth - gutter) {
36
+ left = Math.max(gutter, window.innerWidth - gutter - width);
37
+ }
38
+ setBox({ top: r.bottom + 4, left, width });
39
+ }, [triggerRef, targetWidth]);
40
+
41
+ React.useLayoutEffect(() => {
42
+ if (!open || !enabled) {
43
+ setBox(null);
44
+ return undefined;
45
+ }
46
+ measure();
47
+ window.addEventListener("resize", measure);
48
+ window.addEventListener("scroll", measure, true);
49
+ return () => {
50
+ window.removeEventListener("resize", measure);
51
+ window.removeEventListener("scroll", measure, true);
52
+ };
53
+ }, [open, enabled, measure]);
54
+
55
+ return box;
56
+ }
src/hooks/use-resizable-drawer-width.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ useCallback,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ type RefObject,
7
+ } from "react";
8
+ import { useLocalStorage } from "@uidotdev/usehooks";
9
+
10
+ const PANEL_DRAG_SHIELD_Z_INDEX = 200;
11
+
12
+ type ResizableDrawerEdge = "left" | "right";
13
+
14
+ interface UseResizableDrawerWidthOptions {
15
+ containerRef: RefObject<HTMLElement | null>;
16
+ defaultWidth: number;
17
+ minWidth: number;
18
+ maxWidth: number;
19
+ storageKey: string;
20
+ enabled?: boolean;
21
+ /**
22
+ * Which side of the container the panel is anchored to.
23
+ * - `"right"` (default): width grows leftward from the container's right edge
24
+ * - `"left"`: width grows rightward from the container's left edge
25
+ */
26
+ edge?: ResizableDrawerEdge;
27
+ }
28
+
29
+ export function useResizableDrawerWidth({
30
+ containerRef,
31
+ defaultWidth,
32
+ minWidth,
33
+ maxWidth,
34
+ storageKey,
35
+ enabled = true,
36
+ edge = "right",
37
+ }: UseResizableDrawerWidthOptions) {
38
+ const [persistedWidth, setPersistedWidth] = useLocalStorage<number>(
39
+ storageKey,
40
+ defaultWidth,
41
+ );
42
+
43
+ const clampWidth = useCallback(
44
+ (width: number) => Math.max(minWidth, Math.min(maxWidth, width)),
45
+ [minWidth, maxWidth],
46
+ );
47
+
48
+ const [drawerWidth, setDrawerWidth] = useState(() =>
49
+ clampWidth(persistedWidth),
50
+ );
51
+ const [isDragging, setIsDragging] = useState(false);
52
+ const drawerWidthRef = useRef(drawerWidth);
53
+
54
+ drawerWidthRef.current = drawerWidth;
55
+
56
+ const handleMouseDown = useCallback(
57
+ (event: React.MouseEvent) => {
58
+ if (!enabled) {
59
+ return;
60
+ }
61
+ event.preventDefault();
62
+ setIsDragging(true);
63
+ },
64
+ [enabled],
65
+ );
66
+
67
+ const handleMouseMove = useCallback(
68
+ (event: MouseEvent) => {
69
+ if (!isDragging || !containerRef.current) {
70
+ return;
71
+ }
72
+
73
+ const containerRect = containerRef.current.getBoundingClientRect();
74
+ const nextWidth =
75
+ edge === "left"
76
+ ? event.clientX - containerRect.left
77
+ : containerRect.right - event.clientX;
78
+ setDrawerWidth(clampWidth(nextWidth));
79
+ },
80
+ [clampWidth, containerRef, edge, isDragging],
81
+ );
82
+
83
+ const handleMouseUp = useCallback(() => {
84
+ if (!isDragging) {
85
+ return;
86
+ }
87
+ setIsDragging(false);
88
+ setPersistedWidth(drawerWidthRef.current);
89
+ }, [isDragging, setPersistedWidth]);
90
+
91
+ useLayoutEffect(() => {
92
+ if (!isDragging) {
93
+ return undefined;
94
+ }
95
+
96
+ const shield = document.createElement("div");
97
+ shield.setAttribute("aria-hidden", "true");
98
+ shield.dataset.panelDragShield = "";
99
+ Object.assign(shield.style, {
100
+ position: "fixed",
101
+ inset: "0",
102
+ zIndex: String(PANEL_DRAG_SHIELD_Z_INDEX),
103
+ cursor: "ew-resize",
104
+ });
105
+ document.body.appendChild(shield);
106
+
107
+ document.addEventListener("mousemove", handleMouseMove);
108
+ document.addEventListener("mouseup", handleMouseUp);
109
+ document.body.style.cursor = "ew-resize";
110
+ document.body.style.userSelect = "none";
111
+
112
+ return () => {
113
+ document.removeEventListener("mousemove", handleMouseMove);
114
+ document.removeEventListener("mouseup", handleMouseUp);
115
+ document.body.style.cursor = "";
116
+ document.body.style.userSelect = "";
117
+ shield.remove();
118
+ };
119
+ }, [handleMouseMove, handleMouseUp, isDragging]);
120
+
121
+ return {
122
+ drawerWidth,
123
+ isDragging,
124
+ handleMouseDown,
125
+ };
126
+ }
src/hooks/use-skill-installs.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useMemo } from "react";
2
+ import { useEventStore } from "#/stores/use-event-store";
3
+ import { useSkillInstallBannerStore } from "#/stores/skill-install-banner-store";
4
+ import { detectSkillInstalls } from "#/utils/skill-install-events";
5
+
6
+ /**
7
+ * Skill installs performed by the agent in this conversation (via the
8
+ * bundled add-skill flow), minus the ones the user dismissed. The event
9
+ * store is global, so results are scoped to the conversation it currently
10
+ * holds — a mismatched id (remount race) yields no installs.
11
+ */
12
+ export const useSkillInstalls = (conversationId: string | null | undefined) => {
13
+ const events = useEventStore((s) => s.events);
14
+ const loadedConversationId = useEventStore((s) => s.loadedConversationId);
15
+ const dismissedEventIds = useSkillInstallBannerStore(
16
+ (s) => s.dismissedEventIds,
17
+ );
18
+ const dismiss = useSkillInstallBannerStore((s) => s.dismiss);
19
+
20
+ const installs = useMemo(() => {
21
+ if (!conversationId || conversationId !== loadedConversationId) return [];
22
+ return detectSkillInstalls(events).filter(
23
+ (install) => !dismissedEventIds[install.eventId],
24
+ );
25
+ }, [conversationId, loadedConversationId, events, dismissedEventIds]);
26
+
27
+ const dismissAll = useCallback(
28
+ () => dismiss(installs.map((install) => install.eventId)),
29
+ [dismiss, installs],
30
+ );
31
+
32
+ return { installs, dismissAll };
33
+ };
src/hooks/use-task-list.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import { useEventStore } from "#/stores/use-event-store";
3
+ import { isObservationEvent } from "#/types/agent-server/type-guards";
4
+ import type { TaskTrackerObservation } from "#/types/agent-server/core/base/observation";
5
+ import type { ObservationEvent } from "#/types/agent-server/core/events/observation-event";
6
+
7
+ export interface TaskListItem {
8
+ id: string;
9
+ title: string;
10
+ status: "todo" | "in_progress" | "done";
11
+ notes?: string;
12
+ }
13
+
14
+ function getTaskListFromEvent(
15
+ event: ReturnType<typeof useEventStore.getState>["events"][number],
16
+ ): TaskListItem[] | null {
17
+ if (
18
+ isObservationEvent(event) &&
19
+ event.observation.kind === "TaskTrackerObservation"
20
+ ) {
21
+ const obs = (event as ObservationEvent<TaskTrackerObservation>).observation;
22
+ if (obs.command === "plan") {
23
+ return obs.task_list.map((t, i) => ({
24
+ id: String(i + 1),
25
+ title: t.title,
26
+ status: t.status,
27
+ notes: t.notes || undefined,
28
+ }));
29
+ }
30
+ }
31
+
32
+ return null;
33
+ }
34
+
35
+ export function useTaskList() {
36
+ const events = useEventStore((state) => state.events);
37
+
38
+ return useMemo(() => {
39
+ for (let i = events.length - 1; i >= 0; i -= 1) {
40
+ const taskList = getTaskListFromEvent(events[i]);
41
+ if (taskList) {
42
+ return { taskList, hasTaskList: taskList.length > 0 };
43
+ }
44
+ }
45
+
46
+ return { taskList: [] as TaskListItem[], hasTaskList: false };
47
+ }, [events]);
48
+ }
src/hooks/use-tracking.ts ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Provider } from "#/types/settings";
3
+ import type { BackendKind } from "#/api/backend-registry/types";
4
+ import type { WorkspaceMode } from "#/api/conversation-metadata-store";
5
+ import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics";
6
+ import { getCachedAgentServerVersion } from "#/api/agent-server-compatibility";
7
+ import { useActiveBackend } from "#/contexts/active-backend-context";
8
+ import { useAutomationSdkVersion } from "#/hooks/query/use-automation-sdk-version";
9
+ import {
10
+ type BackendConnectionMethod,
11
+ getBackendTelemetryProperties,
12
+ } from "#/services/telemetry-context";
13
+ import { setTelemetryBackendContext, trackEvent } from "#/services/telemetry";
14
+
15
+ /**
16
+ * Stable semantic identifier for an onboarding link or CTA. Identifies the
17
+ * destination, never the clicked element or its text.
18
+ */
19
+ export type OnboardingLinkId =
20
+ | "configure_llm"
21
+ | "start_conversation"
22
+ | "schedule_task"
23
+ | "customize_agent"
24
+ | "connect_mcp"
25
+ | "join_slack"
26
+ | "open_docs";
27
+
28
+ /** Controlled destination category for `onboarding_link_clicked`. */
29
+ export type OnboardingLinkDestinationType =
30
+ | "community"
31
+ | "integration"
32
+ | "documentation"
33
+ | "settings"
34
+ | "conversation"
35
+ | "automation";
36
+
37
+ /** Onboarding surface that presented the link. */
38
+ export type OnboardingLinkSurface = "landing_checklist" | "onboarding_modal";
39
+
40
+ /**
41
+ * Hook that provides tracking functions with automatic data collection
42
+ * from available hooks (settings, etc.)
43
+ *
44
+ * All events require explicit user consent. The shared PostHog client enforces
45
+ * the canonical consent configured by telemetry.ts; this hook must not gate on
46
+ * backend settings because they can be stale while a backend changes.
47
+ */
48
+ export const useTracking = () => {
49
+ const { backend } = useActiveBackend();
50
+ const automationSdkVersion = useAutomationSdkVersion();
51
+
52
+ const getBackendTelemetryContext = React.useCallback(
53
+ () => ({
54
+ backendKind: backend.kind,
55
+ agentServerVersion:
56
+ backend.kind === "local"
57
+ ? getCachedAgentServerVersion(backend.host)
58
+ : null,
59
+ automationSdkVersion: automationSdkVersion ?? null,
60
+ }),
61
+ [automationSdkVersion, backend.host, backend.kind],
62
+ );
63
+
64
+ React.useEffect(() => {
65
+ setTelemetryBackendContext(getBackendTelemetryContext());
66
+ }, [getBackendTelemetryContext]);
67
+
68
+ // Common properties included in all tracking events
69
+ const commonProperties = {
70
+ current_url: window.location.href,
71
+ };
72
+
73
+ /**
74
+ * PostHog enforces the canonical consent state configured by telemetry.ts.
75
+ * Backend settings are not a capture gate because they can be stale while a
76
+ * backend is being added or switched.
77
+ */
78
+ const track = (event: string, properties: Record<string, unknown> = {}) => {
79
+ setTelemetryBackendContext(getBackendTelemetryContext());
80
+ void trackEvent(event, { ...commonProperties, ...properties });
81
+ };
82
+
83
+ const trackLoginButtonClick = ({ provider }: { provider: Provider }) => {
84
+ track("login_button_clicked", { provider });
85
+ };
86
+
87
+ const trackConversationCreated = ({
88
+ conversationId,
89
+ taskId,
90
+ hasRepository,
91
+ gitProvider,
92
+ hasWorkspace,
93
+ workspaceMode,
94
+ hasInitialQuery,
95
+ agentType,
96
+ hasParentConversation,
97
+ entryPoint,
98
+ }: {
99
+ conversationId: string;
100
+ taskId?: string;
101
+ hasRepository: boolean;
102
+ gitProvider?: Provider;
103
+ hasWorkspace: boolean;
104
+ workspaceMode?: WorkspaceMode;
105
+ hasInitialQuery: boolean;
106
+ agentType?: "default" | "plan";
107
+ hasParentConversation: boolean;
108
+ entryPoint?: string;
109
+ }) => {
110
+ track("conversation_start_requested", {
111
+ conversation_id: conversationId,
112
+ task_id: taskId,
113
+ is_start_task: conversationId.startsWith("task-"),
114
+ has_repository: hasRepository,
115
+ git_provider: gitProvider,
116
+ has_workspace: hasWorkspace,
117
+ workspace_mode: workspaceMode,
118
+ has_initial_query: hasInitialQuery,
119
+ agent_type: agentType,
120
+ has_parent_conversation: hasParentConversation,
121
+ entry_point: entryPoint,
122
+ });
123
+ };
124
+
125
+ const trackPushButtonClick = () => {
126
+ track("push_button_clicked");
127
+ };
128
+
129
+ const trackPullButtonClick = () => {
130
+ track("pull_button_clicked");
131
+ };
132
+
133
+ const trackCreatePrButtonClick = () => {
134
+ track("create_pr_button_clicked");
135
+ };
136
+
137
+ const trackUserSignupCompleted = () => {
138
+ track("user_signup_completed", {
139
+ signup_timestamp: new Date().toISOString(),
140
+ });
141
+ };
142
+
143
+ const trackPrebuiltAutomationEnabled = ({
144
+ automationId,
145
+ automationName,
146
+ automationCategory,
147
+ }: {
148
+ automationId?: string;
149
+ automationName: string;
150
+ automationCategory?: string;
151
+ }) => {
152
+ track("prebuilt_automation_enabled", {
153
+ automation_id: automationId,
154
+ automation_name: automationName,
155
+ automation_category: automationCategory,
156
+ });
157
+ };
158
+
159
+ /**
160
+ * The setup funnel: opened → validated → created, or failed.
161
+ *
162
+ * A manifest declares no analytics of its own, so the stages are the same for
163
+ * every entry and only the id it carries varies.
164
+ */
165
+ const trackAutomationSetupOpened = ({
166
+ automationId,
167
+ }: {
168
+ automationId: string;
169
+ }) => {
170
+ track("automation_setup_opened", { automation_id: automationId });
171
+ };
172
+
173
+ const trackAutomationSetupValidated = ({
174
+ automationId,
175
+ }: {
176
+ automationId: string;
177
+ }) => {
178
+ track("automation_setup_validated", { automation_id: automationId });
179
+ };
180
+
181
+ const trackAutomationSetupCreated = ({
182
+ automationId,
183
+ setupMode,
184
+ }: {
185
+ automationId: string;
186
+ setupMode: string;
187
+ }) => {
188
+ track("automation_setup_created", {
189
+ automation_id: automationId,
190
+ setup_mode: setupMode,
191
+ });
192
+ };
193
+
194
+ const trackAutomationSetupFailed = ({
195
+ automationId,
196
+ setupMode,
197
+ }: {
198
+ automationId: string;
199
+ setupMode: string;
200
+ }) => {
201
+ track("automation_setup_failed", {
202
+ automation_id: automationId,
203
+ setup_mode: setupMode,
204
+ });
205
+ };
206
+
207
+ const trackInitialQuerySubmitted = ({
208
+ entryPoint,
209
+ queryCharacterLength,
210
+ replayJsonSize,
211
+ }: {
212
+ entryPoint: string;
213
+ queryCharacterLength: number;
214
+ replayJsonSize?: number;
215
+ }) => {
216
+ track("initial_query_submitted", {
217
+ entry_point: entryPoint,
218
+ query_character_length: queryCharacterLength,
219
+ replay_json_size: replayJsonSize,
220
+ });
221
+ };
222
+
223
+ const trackUserMessageSent = ({
224
+ sessionMessageCount,
225
+ currentMessageLength,
226
+ }: {
227
+ sessionMessageCount: number;
228
+ currentMessageLength: number;
229
+ }) => {
230
+ track("user_message_sent", {
231
+ session_message_count: sessionMessageCount,
232
+ current_message_length: currentMessageLength,
233
+ });
234
+ };
235
+
236
+ const trackDownloadVsCodeButtonClicked = () => {
237
+ track("download_via_vscode_button_clicked");
238
+ };
239
+
240
+ const trackSettingsSaved = ({
241
+ llmModel,
242
+ llmApiKeySet,
243
+ searchApiKeySet,
244
+ remoteRuntimeResourceFactor,
245
+ }: {
246
+ llmModel: unknown;
247
+ llmApiKeySet: "SET" | "UNSET";
248
+ searchApiKeySet: "SET" | "UNSET";
249
+ remoteRuntimeResourceFactor?: unknown;
250
+ }) => {
251
+ track("settings_saved", {
252
+ LLM_MODEL: llmModel,
253
+ LLM_API_KEY_SET: llmApiKeySet,
254
+ SEARCH_API_KEY_SET: searchApiKeySet,
255
+ REMOTE_RUNTIME_RESOURCE_FACTOR: remoteRuntimeResourceFactor,
256
+ });
257
+ };
258
+
259
+ const trackMcpConfigUpdated = ({
260
+ sseServersCount,
261
+ stdioServersCount,
262
+ }: {
263
+ sseServersCount: number;
264
+ stdioServersCount: number;
265
+ }) => {
266
+ track("mcp_config_updated", {
267
+ has_mcp_config: true,
268
+ sse_servers_count: sseServersCount,
269
+ stdio_servers_count: stdioServersCount,
270
+ });
271
+ };
272
+
273
+ const trackDownloadTrajectoryButtonClicked = () => {
274
+ track("download_trajectory_button_clicked");
275
+ };
276
+
277
+ const trackConversationExported = (format: "markdown" | "html") => {
278
+ track("conversation_exported", { format });
279
+ };
280
+
281
+ const trackAutomationCreatedButton = ({
282
+ backendKind,
283
+ }: {
284
+ backendKind: BackendKind;
285
+ }) => {
286
+ track("automation_created_button", { backend_kind: backendKind });
287
+ };
288
+
289
+ const trackAutomationExecuted = ({
290
+ backendKind,
291
+ }: {
292
+ backendKind: BackendKind;
293
+ }) => {
294
+ track("automation_executed", { backend_kind: backendKind });
295
+ };
296
+
297
+ const trackAutomationDeleted = ({
298
+ backendKind,
299
+ }: {
300
+ backendKind: BackendKind;
301
+ }) => {
302
+ track("automation_deleted", { backend_kind: backendKind });
303
+ };
304
+
305
+ const trackAutomationDisableButton = ({
306
+ backendKind,
307
+ }: {
308
+ backendKind: BackendKind;
309
+ }) => {
310
+ track("automation_disable_button", { backend_kind: backendKind });
311
+ };
312
+
313
+ const trackAutomationEdited = ({
314
+ backendKind,
315
+ }: {
316
+ backendKind: BackendKind;
317
+ }) => {
318
+ track("automation_edited", { backend_kind: backendKind });
319
+ };
320
+
321
+ const trackAutomationExported = ({
322
+ backendKind,
323
+ }: {
324
+ backendKind: BackendKind;
325
+ }) => {
326
+ track("automation_exported", { backend_kind: backendKind });
327
+ };
328
+
329
+ const trackAutomationActivityLogExported = ({
330
+ backendKind,
331
+ format,
332
+ }: {
333
+ backendKind: BackendKind;
334
+ format: "json" | "csv";
335
+ }) => {
336
+ track("automation_activity_log_exported", {
337
+ backend_kind: backendKind,
338
+ format,
339
+ });
340
+ };
341
+
342
+ const trackAutomationImported = ({
343
+ backendKind,
344
+ }: {
345
+ backendKind: BackendKind;
346
+ }) => {
347
+ track("automation_imported", { backend_kind: backendKind });
348
+ };
349
+
350
+ const trackGitSyncConfigUpdated = ({
351
+ backendKind,
352
+ }: {
353
+ backendKind: BackendKind;
354
+ }) => {
355
+ track("git_sync_config_updated", { backend_kind: backendKind });
356
+ };
357
+
358
+ const trackGitSyncTriggered = ({
359
+ backendKind,
360
+ }: {
361
+ backendKind: BackendKind;
362
+ }) => {
363
+ track("git_sync_triggered", { backend_kind: backendKind });
364
+ };
365
+
366
+ const trackBackendAdded = ({
367
+ backendKind,
368
+ connectionMethod,
369
+ hasApiKey,
370
+ source,
371
+ agentServerVersion,
372
+ backendVersion,
373
+ }: {
374
+ backendKind: BackendKind;
375
+ connectionMethod: BackendConnectionMethod;
376
+ hasApiKey: boolean;
377
+ source?: CloudConnectionSource;
378
+ agentServerVersion?: string | null;
379
+ backendVersion?: string | null;
380
+ }) => {
381
+ track("backend_added", {
382
+ ...getBackendTelemetryProperties({
383
+ backendKind,
384
+ agentServerVersion,
385
+ backendVersion,
386
+ connectionMethod,
387
+ }),
388
+ has_api_key: hasApiKey,
389
+ source,
390
+ });
391
+ };
392
+
393
+ const trackOnboardingStarted = () => {
394
+ track("onboarding_started");
395
+ };
396
+
397
+ const trackOnboardingStepViewed = ({
398
+ step,
399
+ stepIndex,
400
+ totalSteps,
401
+ agent,
402
+ }: {
403
+ step: string;
404
+ stepIndex: number;
405
+ totalSteps: number;
406
+ agent: string;
407
+ }) => {
408
+ track("onboarding_step_viewed", {
409
+ step,
410
+ step_index: stepIndex,
411
+ total_steps: totalSteps,
412
+ agent,
413
+ });
414
+ };
415
+
416
+ const trackOnboardingCompleted = ({ agent }: { agent: string }) => {
417
+ track("onboarding_completed", { agent });
418
+ };
419
+
420
+ const trackOnboardingSkipped = ({
421
+ step,
422
+ stepIndex,
423
+ totalSteps,
424
+ agent,
425
+ }: {
426
+ step: string;
427
+ stepIndex: number;
428
+ totalSteps: number;
429
+ agent: string;
430
+ }) => {
431
+ track("onboarding_skipped", {
432
+ step,
433
+ step_index: stepIndex,
434
+ total_steps: totalSteps,
435
+ agent,
436
+ });
437
+ };
438
+
439
+ const trackOnboardingLinkClicked = ({
440
+ linkId,
441
+ destinationType,
442
+ surface,
443
+ checklistItem,
444
+ stepId,
445
+ isExternal,
446
+ }: {
447
+ linkId: OnboardingLinkId;
448
+ destinationType: OnboardingLinkDestinationType;
449
+ surface: OnboardingLinkSurface;
450
+ checklistItem?: Exclude<OnboardingLinkId, "open_docs">;
451
+ stepId?: string;
452
+ isExternal: boolean;
453
+ }) => {
454
+ track("onboarding_link_clicked", {
455
+ link_id: linkId,
456
+ destination_type: destinationType,
457
+ surface,
458
+ checklist_item: checklistItem,
459
+ step_id: stepId,
460
+ is_external: isExternal,
461
+ });
462
+ };
463
+
464
+ return {
465
+ trackLoginButtonClick,
466
+ trackConversationCreated,
467
+ trackPushButtonClick,
468
+ trackPullButtonClick,
469
+ trackCreatePrButtonClick,
470
+ trackUserSignupCompleted,
471
+ trackPrebuiltAutomationEnabled,
472
+ trackAutomationSetupOpened,
473
+ trackAutomationSetupValidated,
474
+ trackAutomationSetupCreated,
475
+ trackAutomationSetupFailed,
476
+ trackInitialQuerySubmitted,
477
+ trackUserMessageSent,
478
+ trackDownloadVsCodeButtonClicked,
479
+ trackSettingsSaved,
480
+ trackMcpConfigUpdated,
481
+ trackDownloadTrajectoryButtonClicked,
482
+ trackConversationExported,
483
+ trackAutomationCreatedButton,
484
+ trackAutomationExecuted,
485
+ trackAutomationDeleted,
486
+ trackAutomationDisableButton,
487
+ trackAutomationEdited,
488
+ trackAutomationExported,
489
+ trackAutomationActivityLogExported,
490
+ trackAutomationImported,
491
+ trackGitSyncConfigUpdated,
492
+ trackGitSyncTriggered,
493
+ trackBackendAdded,
494
+ trackOnboardingStarted,
495
+ trackOnboardingStepViewed,
496
+ trackOnboardingCompleted,
497
+ trackOnboardingSkipped,
498
+ trackOnboardingLinkClicked,
499
+ };
500
+ };
src/hooks/use-user-providers.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { convertRawProvidersToList } from "#/utils/convert-raw-providers-to-list";
3
+ import { useSettings } from "./query/use-settings";
4
+
5
+ export const useUserProviders = () => {
6
+ const { data: settings, isLoading: isLoadingSettings } = useSettings();
7
+
8
+ const providers = React.useMemo(
9
+ () => convertRawProvidersToList(settings?.provider_tokens_set),
10
+ [settings?.provider_tokens_set],
11
+ );
12
+
13
+ return {
14
+ providers,
15
+ isLoadingSettings,
16
+ };
17
+ };
src/styles/agent-server-ui-style-scope.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const AGENT_SERVER_UI_SCOPE_ATTRIBUTE = "data-agent-server-ui";
2
+ export const AGENT_SERVER_UI_SCOPE_SELECTOR = `[${AGENT_SERVER_UI_SCOPE_ATTRIBUTE}]`;
3
+ export const AGENT_SERVER_UI_DEFAULT_THEME = "dark" as const;
4
+
5
+ export type AgentServerUITheme = "dark" | "light" | "default";
6
+
7
+ /** Brand tokens controlled by color themes — not inlined on AgentServerUIRoot. */
8
+ export const AGENT_SERVER_UI_THEMEABLE_BRAND_VARIABLES = [
9
+ "--oh-color-primary",
10
+ "--oh-accent",
11
+ "--oh-warning",
12
+ ] as const;
13
+
14
+ export type AgentServerUIThemeableBrandVariable =
15
+ (typeof AGENT_SERVER_UI_THEMEABLE_BRAND_VARIABLES)[number];
16
+
17
+ export const AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES = {
18
+ // Brand/button colors (--oh-color-primary, --oh-accent, --oh-warning) live in
19
+ // tailwind.css and are overridden at runtime by applyColorTheme(); keep them
20
+ // out of inline defaults so theme tokens are not blocked by element.style.
21
+ "--oh-color-logo": "#cfb755",
22
+ "--oh-color-base": "var(--cool-grey-950)",
23
+ "--oh-color-base-secondary": "var(--cool-grey-925)",
24
+ "--oh-color-danger": "#e76a5e",
25
+ "--oh-color-success": "#a5e75e",
26
+ "--oh-color-basic": "var(--cool-grey-400)",
27
+ "--oh-color-tertiary": "var(--cool-grey-800)",
28
+ "--oh-color-tertiary-light": "var(--cool-grey-300)",
29
+ "--oh-color-content": "var(--cool-grey-100)",
30
+ "--oh-color-content-2": "var(--cool-grey-50)",
31
+ "--oh-background": "var(--cool-grey-950)",
32
+ "--oh-foreground": "var(--cool-grey-100)",
33
+ "--oh-surface": "var(--cool-grey-925)",
34
+ "--oh-surface-foreground": "var(--cool-grey-100)",
35
+ "--oh-surface-raised": "var(--cool-grey-900)",
36
+ "--oh-surface-deep": "var(--cool-grey-975)",
37
+ "--oh-overlay": "var(--cool-grey-925)",
38
+ "--oh-overlay-foreground": "var(--cool-grey-100)",
39
+ "--oh-muted": "var(--cool-grey-400)",
40
+ "--oh-text-secondary": "var(--cool-grey-300)",
41
+ "--oh-text-tertiary": "var(--cool-grey-200)",
42
+ "--oh-text-dim": "var(--cool-grey-500)",
43
+ "--oh-text-subtle": "var(--cool-grey-600)",
44
+ "--oh-interactive-hover": "var(--cool-grey-700)",
45
+ "--oh-interactive-hover-low": "var(--cool-grey-900)",
46
+ "--oh-interactive-active": "var(--cool-grey-800)",
47
+ "--oh-interactive-selected": "var(--cool-grey-600)",
48
+ "--oh-scrollbar": "color-mix(in srgb, var(--cool-grey-400) 30%, transparent)",
49
+ "--oh-scrollbar-hover":
50
+ "color-mix(in srgb, var(--cool-grey-400) 50%, transparent)",
51
+ "--oh-default": "var(--cool-grey-800)",
52
+ "--oh-default-foreground": "var(--cool-grey-100)",
53
+ "--oh-accent-foreground": "var(--cool-grey-950)",
54
+ "--oh-success": "#a5e75e",
55
+ "--oh-success-foreground": "var(--cool-grey-950)",
56
+ "--oh-warning-foreground": "var(--cool-grey-950)",
57
+ "--oh-danger": "#e76a5e",
58
+ "--oh-danger-foreground": "var(--cool-grey-50)",
59
+ "--oh-segment": "var(--cool-grey-925)",
60
+ "--oh-segment-foreground": "var(--cool-grey-100)",
61
+ "--oh-border-width": "1px",
62
+ "--oh-field-border-width": "1px",
63
+ "--oh-border": "var(--cool-grey-700)",
64
+ "--oh-border-input": "var(--cool-grey-600)",
65
+ "--oh-border-subtle": "var(--cool-grey-800)",
66
+ "--oh-separator": "rgba(113, 120, 136, 0.5)",
67
+ "--oh-focus": "#ffffff",
68
+ "--oh-status-success": "#1FBD53",
69
+ "--oh-status-error": "#FF684E",
70
+ "--oh-link": "var(--cool-grey-100)",
71
+ "--oh-radius": "8px",
72
+ "--oh-field-radius": "8px",
73
+ "--oh-surface-shadow": "none",
74
+ "--oh-overlay-shadow": "none",
75
+ "--oh-field-shadow": "none",
76
+ "--oh-bg-dark": "var(--cool-grey-950)",
77
+ "--oh-bg-light": "var(--cool-grey-900)",
78
+ "--oh-bg-input": "var(--cool-grey-800)",
79
+ "--oh-bg-workspace": "var(--cool-grey-925)",
80
+ "--oh-text-editor-base": "var(--cool-grey-400)",
81
+ "--oh-text-editor-active": "var(--cool-grey-300)",
82
+ "--oh-bg-editor-sidebar": "var(--cool-grey-925)",
83
+ "--oh-bg-editor-active": "var(--cool-grey-900)",
84
+ "--oh-border-editor-sidebar": "var(--cool-grey-800)",
85
+ "--oh-bg-neutral-muted":
86
+ "color-mix(in srgb, var(--cool-grey-300) 20%, transparent)",
87
+ } as const;
88
+
89
+ export type AgentServerUICssVariableName =
90
+ | keyof typeof AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES
91
+ | AgentServerUIThemeableBrandVariable;
92
+
93
+ export type AgentServerUIStyleOverrides = Partial<
94
+ Record<AgentServerUICssVariableName, string>
95
+ >;
96
+
97
+ const GLOBAL_SCOPE_SELECTORS = new Set([":root", "body", "html"]);
98
+
99
+ export function transformAgentServerUISelector(
100
+ prefix: string,
101
+ selector: string,
102
+ prefixedSelector: string,
103
+ ): string {
104
+ if (selector.includes(prefix)) {
105
+ return selector;
106
+ }
107
+
108
+ if (selector.includes(":host")) {
109
+ return selector.replaceAll(":host", prefix);
110
+ }
111
+
112
+ if (GLOBAL_SCOPE_SELECTORS.has(selector.trim())) {
113
+ return prefix;
114
+ }
115
+
116
+ return prefixedSelector;
117
+ }
src/utils/acp-auth-display.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AcpAuthStatus } from "#/hooks/query/use-acp-auth-status";
2
+
3
+ /**
4
+ * Resolved display state for the ACP auth banner.
5
+ *
6
+ * - ``"signed-in"`` — the host-login probe confirmed a session (native backend).
7
+ * - ``"checking"`` — the first probe is still in flight.
8
+ * - ``"configured"`` — the probe can't confirm a login (e.g. a container with no
9
+ * interactive CLI, or a cloud backend where the probe doesn't run), but a
10
+ * credential for the provider exists in the active backend's secret store.
11
+ * - ``"none"`` — no confirmed login and no stored credential; show nothing.
12
+ */
13
+ export type AcpAuthDisplay = "signed-in" | "checking" | "configured" | "none";
14
+
15
+ interface AcpAuthDisplayInput {
16
+ /** Result of the host-login probe (only meaningful on a native backend). */
17
+ status: AcpAuthStatus;
18
+ /** True while the first probe for this provider is in flight. */
19
+ isChecking: boolean;
20
+ /**
21
+ * Whether a credential for the provider exists in the active backend's secret
22
+ * store. Unlike {@link status}, this signal is available on Docker and cloud
23
+ * backends, so it can convey an accurate state where the probe goes silent.
24
+ */
25
+ credentialsConfigured: boolean;
26
+ }
27
+
28
+ /**
29
+ * Decide what the ACP auth banner should show, given the (host-only) login
30
+ * probe and the backend-truthful "a credential is stored" signal.
31
+ *
32
+ * Precedence preserves the existing banner behavior — a probe-confirmed login
33
+ * wins, then the in-flight spinner — and only adds ``"configured"`` in the gap
34
+ * that previously rendered nothing. A stored credential never reports as
35
+ * ``"signed-in"``: only the probe can confirm an actual host login. See #1244.
36
+ */
37
+ export function resolveAcpAuthDisplay({
38
+ status,
39
+ isChecking,
40
+ credentialsConfigured,
41
+ }: AcpAuthDisplayInput): AcpAuthDisplay {
42
+ if (status === "authenticated") return "signed-in";
43
+ if (isChecking) return "checking";
44
+ if (credentialsConfigured) return "configured";
45
+ return "none";
46
+ }
src/utils/acp-command.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ``shell-quote`` is a CJS module that does ``module.exports = { parse, quote }``;
2
+ // Vite's ESM interop can resolve a default/namespace import but not named
3
+ // imports against that shape (the dev server crashes with "does not provide
4
+ // an export named 'parse'"). Namespace import works on both the dev server
5
+ // and the Rollup-based prod build. Used only for the ``quote`` direction —
6
+ // see ``parseCommand`` below for why we don't use ``shell-quote.parse``.
7
+ import * as shellQuote from "shell-quote";
8
+
9
+ const { quote } = shellQuote;
10
+
11
+ /**
12
+ * Parse a single-string command into argv tokens for ``acp_command``.
13
+ *
14
+ * Used by the Settings → Agent textarea — the user types one human-readable
15
+ * command (e.g. ``bash -c "echo hello world"``) and we convert it into the
16
+ * ``string[]`` shape that the agent-server's ``ACPAgent.acp_command``
17
+ * expects. The agent-server passes that array straight to
18
+ * ``subprocess.create_subprocess_exec``; no shell is involved on the spawn
19
+ * side, so this parser only needs to handle argv-style word splitting
20
+ * with quote/escape support — *not* shell metasyntax.
21
+ *
22
+ * Why a custom tokenizer and **not** ``shell-quote.parse``:
23
+ *
24
+ * ``shell-quote.parse`` treats ``?``, ``*``, ``$VAR``, redirects, and
25
+ * comments as shell syntax and emits non-string AST nodes for them.
26
+ * Filtering to strings would silently drop entire argv tokens. The
27
+ * concrete data-corruption case is a URL with a query string —
28
+ *
29
+ * node acp.js --endpoint https://example.com/acp?tenant=abc
30
+ *
31
+ * ``shell-quote`` reads ``?tenant=abc`` as a glob pattern and returns
32
+ * ``["node","acp.js","--endpoint",{op:"glob",…}]``, so the saved
33
+ * ``acp_command`` becomes ``["node","acp.js","--endpoint"]`` — the URL
34
+ * vanishes. The agent-server then spawns a broken command and the user
35
+ * gets a confusing runtime error far from the configuration UI.
36
+ *
37
+ * The replacement tokenizer treats every non-whitespace, non-quote
38
+ * character as part of the current token: ``?``, ``*``, ``$``, ``|``,
39
+ * ``>``, ``#``, backticks all round-trip verbatim. Shell-only constructs
40
+ * (pipes, redirects, env-var expansion, command substitution) would
41
+ * land as literal argv entries — which is what the user typed and what
42
+ * ``subprocess.create_subprocess_exec`` will see. That's correct: a
43
+ * user who types ``foo | bar`` into the Settings → Agent textarea is
44
+ * configuring a literal command, not a shell pipeline; ``foo`` doesn't
45
+ * actually pipe into ``bar``, but neither does it silently disappear.
46
+ *
47
+ * Quoting rules supported:
48
+ * - whitespace separates tokens
49
+ * - single quotes: literal until the next ``'`` (no escapes inside,
50
+ * matching POSIX shell)
51
+ * - double quotes: literal until the next ``"`` (with ``\\"`` and
52
+ * ``\\\\`` honored as escapes; no $-expansion)
53
+ * - backslash outside quotes: escapes the next character (whitespace,
54
+ * quote, or anything else — turns it into a literal)
55
+ * - explicit empty quoted segments (``""`` / ``''``) produce an
56
+ * empty-string token, matching the round-trip rule in
57
+ * ``formatCommand``
58
+ *
59
+ * Unterminated quotes are tolerated: the current token closes at EOF
60
+ * with whatever was accumulated. A throw here would crash the
61
+ * Settings → Agent page mid-render; the Save button is already gated
62
+ * on a non-empty argv so a recoverable miss can't be silently saved
63
+ * either way.
64
+ */
65
+ export function parseCommand(value: string): string[] {
66
+ const tokens: string[] = [];
67
+ let i = 0;
68
+ let current = "";
69
+ let inToken = false;
70
+
71
+ while (i < value.length) {
72
+ const ch = value[i];
73
+
74
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
75
+ if (inToken) {
76
+ tokens.push(current);
77
+ current = "";
78
+ inToken = false;
79
+ }
80
+ i += 1;
81
+ continue;
82
+ }
83
+
84
+ if (ch === "'") {
85
+ // Single-quoted segment: literal until the next single quote.
86
+ // No escapes inside (POSIX shell semantics).
87
+ inToken = true;
88
+ i += 1;
89
+ while (i < value.length && value[i] !== "'") {
90
+ current += value[i];
91
+ i += 1;
92
+ }
93
+ // Skip the closing quote if present. Unterminated → EOF closes.
94
+ if (i < value.length) i += 1;
95
+ continue;
96
+ }
97
+
98
+ if (ch === '"') {
99
+ // Double-quoted segment: literal with backslash escapes for
100
+ // ``\\"`` and ``\\\\``. We intentionally do NOT expand $VAR
101
+ // (so a user typing ``"--key=$X"`` keeps it literal — the
102
+ // agent-server doesn't run a shell anyway). Other backslash
103
+ // sequences pass through verbatim (matches what most users
104
+ // expect when copying paths with backslashes from Windows
105
+ // examples; corner-case differences from POSIX aren't worth
106
+ // the complexity here).
107
+ inToken = true;
108
+ i += 1;
109
+ while (i < value.length && value[i] !== '"') {
110
+ if (value[i] === "\\" && i + 1 < value.length) {
111
+ const next = value[i + 1];
112
+ if (next === '"' || next === "\\") {
113
+ current += next;
114
+ i += 2;
115
+ continue;
116
+ }
117
+ }
118
+ current += value[i];
119
+ i += 1;
120
+ }
121
+ if (i < value.length) i += 1;
122
+ continue;
123
+ }
124
+
125
+ if (ch === "\\" && i + 1 < value.length) {
126
+ // Unquoted backslash escapes the next character (whitespace,
127
+ // quote, glob char, anything). Useful for typing literal spaces
128
+ // in a path or escaping a literal quote.
129
+ inToken = true;
130
+ current += value[i + 1];
131
+ i += 2;
132
+ continue;
133
+ }
134
+
135
+ // Every other character — including ``?``, ``*``, ``$``, ``|``,
136
+ // ``>``, ``#``, ``&``, ``;``, ``(``, ``)`` — is a literal part of
137
+ // the current token. Shell-metasyntax filtering happens at the
138
+ // OS/shell boundary, which we don't cross.
139
+ inToken = true;
140
+ current += ch;
141
+ i += 1;
142
+ }
143
+
144
+ if (inToken) {
145
+ tokens.push(current);
146
+ }
147
+ return tokens;
148
+ }
149
+
150
+ // Tokens that need shell-quoting when rendering back to a string —
151
+ // whitespace, quotes, backslashes, redirects/pipes/globs, and the
152
+ // command-separators. ``@``, ``/``, ``-``, ``.``, ``+``, ``=`` and
153
+ // other punctuation that's common in package names and URLs are
154
+ // safe in argv-only contexts (the agent-server execs the array, no
155
+ // shell intermediary), so we leave them alone — otherwise
156
+ // ``npx -y @org/pkg`` would render as ``npx -y \@org/pkg`` and that's
157
+ // a hostile read-back in the textarea.
158
+ //
159
+ // Note: ``?``, ``*``, ``$``, ``|``, ``>``, ``#``, ``&``, ``;``, ``(``,
160
+ // ``)`` no longer carry shell meaning in {@link parseCommand} (which
161
+ // is purely an argv tokenizer), so a token containing them would
162
+ // round-trip fine without quoting. We still quote on output because
163
+ // users frequently switch between this textarea and an actual shell
164
+ // (copy-paste workflows), and quoting matches the conservative
165
+ // expectation "if it would need quoting in a shell, show it quoted."
166
+ const SHELL_UNSAFE = /[\s"'\\$`&|;<>(){}*?#!~[\]]/;
167
+
168
+ /**
169
+ * Render a ``string[]`` argv back into a single string the textarea
170
+ * can display. Tokens that *would* need shell quoting (whitespace,
171
+ * quotes, redirects, …) go through ``shell-quote.quote`` for correct
172
+ * escaping; tokens that are already shell-safe (the overwhelming
173
+ * majority of package names and CLI flags) round-trip verbatim. The
174
+ * output remains a valid input to {@link parseCommand}.
175
+ */
176
+ export function formatCommand(command: readonly string[]): string {
177
+ return command
178
+ .map((tok) =>
179
+ // Quote any token that:
180
+ // * contains a shell-significant character (whitespace, quotes,
181
+ // redirects, …), so it doesn't get re-split on parse, OR
182
+ // * is the empty string — without explicit quoting,
183
+ // ``["bash", "-c", ""]`` would render as ``"bash -c "`` and
184
+ // round-trip back to ``["bash", "-c"]``, silently dropping
185
+ // the (rare but valid) empty argument.
186
+ SHELL_UNSAFE.test(tok) || tok === "" ? quote([tok]) : tok,
187
+ )
188
+ .join(" ");
189
+ }
src/utils/acp-error-codes.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { I18nKey } from "#/i18n/declaration";
2
+
3
+ /**
4
+ * Structured `code` values the SDK's ACPAgent puts on a ConversationErrorEvent
5
+ * (see software-agent-sdk `acp_agent.py`). The banner uses them to show a
6
+ * code-specific header and, for credential failures, a recovery action.
7
+ */
8
+ export const ACP_AUTH_REQUIRED_CODE = "ACPAuthRequired";
9
+
10
+ const ACP_ERROR_HEADER_KEYS: Record<string, I18nKey> = {
11
+ ACPAuthRequired: I18nKey.ERROR$ACP_AUTH_REQUIRED_TITLE,
12
+ // Spawn/init/prompt/usage-policy failures share the generic "Agent error"
13
+ // header; their detail already carries the specific cause.
14
+ ACPSpawnError: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE,
15
+ ACPInitError: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE,
16
+ ACPPromptError: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE,
17
+ UsagePolicyRefusal: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE,
18
+ };
19
+
20
+ /** Localized header key for an error code, or null when the code is unknown. */
21
+ export function getAcpErrorHeaderKey(code?: string | null): I18nKey | null {
22
+ if (!code) return null;
23
+ return ACP_ERROR_HEADER_KEYS[code] ?? null;
24
+ }
25
+
26
+ /** Whether the error is a credential failure that warrants a re-auth action. */
27
+ export function isAcpAuthErrorCode(code?: string | null): boolean {
28
+ return code === ACP_AUTH_REQUIRED_CODE;
29
+ }
src/utils/agent-state-emoji.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
2
+
3
+ /**
4
+ * Maps a conversation's execution status to a single emoji that visually
5
+ * conveys the agent's state in the browser tab title.
6
+ *
7
+ * ✅ green check — agent finished or is otherwise waiting on the user
8
+ * 🟢 green circle — agent is actively running
9
+ * ⚪ gray circle — agent is paused/stopped
10
+ * 🔴 red circle — agent is in an error state
11
+ *
12
+ * Returns null when the status is unknown so callers can omit the prefix.
13
+ */
14
+ export function getAgentStateEmoji(
15
+ status: ExecutionStatus | null | undefined,
16
+ ): string | null {
17
+ switch (status) {
18
+ case ExecutionStatus.RUNNING:
19
+ return "🟢";
20
+ case ExecutionStatus.FINISHED:
21
+ case ExecutionStatus.IDLE:
22
+ case ExecutionStatus.WAITING_FOR_CONFIRMATION:
23
+ return "✅";
24
+ case ExecutionStatus.PAUSED:
25
+ return "⚪";
26
+ case ExecutionStatus.ERROR:
27
+ case ExecutionStatus.STUCK:
28
+ return "🔴";
29
+ default:
30
+ return null;
31
+ }
32
+ }
src/utils/api-error-message.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * Extract the parsed response body from a failed API call.
5
+ *
6
+ * Handles both transports the app uses: local agent-server calls that
7
+ * throw an `AxiosError` (body under `error.response.data`) and cloud
8
+ * calls through the shared TypeScript client that throw an `HttpError`
9
+ * (parsed body directly under `error.response`).
10
+ */
11
+ export function getApiErrorBody(error: unknown): unknown {
12
+ if (axios.isAxiosError(error)) return error.response?.data;
13
+ if (error instanceof Error && "response" in error) {
14
+ return (error as { response?: unknown }).response;
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ /**
20
+ * Extract a human-readable message from a failed API call. Prefers the
21
+ * server-provided `message`/`detail` fields, then the `Error` message,
22
+ * then `fallback`.
23
+ */
24
+ export function getApiErrorMessage(error: unknown, fallback: string): string {
25
+ const body = getApiErrorBody(error);
26
+
27
+ if (body && typeof body === "object") {
28
+ const { message, detail } = body as {
29
+ message?: unknown;
30
+ detail?: unknown;
31
+ };
32
+ if (typeof message === "string" && message) return message;
33
+ if (typeof detail === "string" && detail) return detail;
34
+ }
35
+
36
+ if (error instanceof Error && error.message) return error.message;
37
+ return fallback;
38
+ }
src/utils/automation-activity-log-export.ts ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ ActivityLogExportFormat,
3
+ Automation,
4
+ AutomationRun,
5
+ AutomationRunExportRow,
6
+ } from "#/types/automation";
7
+ import { downloadBlob } from "#/utils/utils";
8
+ import AutomationService from "#/api/automation-service/automation-service.api";
9
+
10
+ const EXPORT_PAGE_SIZE = 100;
11
+
12
+ const CSV_COLUMNS = [
13
+ "run_id",
14
+ "automation_id",
15
+ "automation_name",
16
+ "trigger",
17
+ "start_time",
18
+ "end_time",
19
+ "duration_seconds",
20
+ "status",
21
+ "conversation_id",
22
+ "conversation_url",
23
+ "error",
24
+ // Appended rather than grouped with the other run metrics so existing
25
+ // consumers that read the CSV by column position keep working.
26
+ "cost",
27
+ "phase",
28
+ ] as const;
29
+
30
+ export function getActivityLogExportFilename(
31
+ automation: Pick<Automation, "id" | "name">,
32
+ format: ActivityLogExportFormat,
33
+ ): string {
34
+ const slug = automation.name
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, "-")
38
+ .replace(/^-+|-+$/g, "");
39
+ return `${slug || automation.id}.activity-log.${format}`;
40
+ }
41
+
42
+ function csvEscape(value: string): string {
43
+ if (/[",\n\r]/.test(value)) {
44
+ return `"${value.replace(/"/g, '""')}"`;
45
+ }
46
+ return value;
47
+ }
48
+
49
+ export function serializeActivityLogRowsCsv(
50
+ rows: AutomationRunExportRow[],
51
+ ): string {
52
+ const lines: string[] = [CSV_COLUMNS.join(",")];
53
+ for (const row of rows) {
54
+ const values = CSV_COLUMNS.map((key) => {
55
+ const raw = row[key];
56
+ if (raw == null) return "";
57
+ if (key === "trigger") {
58
+ return csvEscape(JSON.stringify(raw));
59
+ }
60
+ return csvEscape(String(raw));
61
+ });
62
+ lines.push(values.join(","));
63
+ }
64
+ return `${lines.join("\n")}\n`;
65
+ }
66
+
67
+ export function buildConversationUrl(
68
+ conversationId: string | null,
69
+ conversationBaseUrl?: string,
70
+ ): string | null {
71
+ if (!conversationId) return null;
72
+ const path = `/conversations/${conversationId}`;
73
+ if (!conversationBaseUrl) return path;
74
+ return `${conversationBaseUrl.replace(/\/$/, "")}${path}`;
75
+ }
76
+
77
+ export function mapAutomationRunToExportRow(
78
+ run: AutomationRun,
79
+ automation: Pick<Automation, "id" | "name" | "trigger">,
80
+ conversationBaseUrl?: string,
81
+ ): AutomationRunExportRow {
82
+ const start = run.started_at || null;
83
+ const end = run.completed_at;
84
+ let duration_seconds: number | null = null;
85
+ if (start && end) {
86
+ duration_seconds =
87
+ (new Date(end).getTime() - new Date(start).getTime()) / 1000;
88
+ }
89
+
90
+ return {
91
+ run_id: run.id,
92
+ automation_id: automation.id,
93
+ automation_name: automation.name,
94
+ trigger: automation.trigger,
95
+ start_time: start,
96
+ end_time: end,
97
+ duration_seconds,
98
+ status: run.status,
99
+ conversation_id: run.conversation_id,
100
+ conversation_url: buildConversationUrl(
101
+ run.conversation_id,
102
+ conversationBaseUrl,
103
+ ),
104
+ error: run.error_detail,
105
+ // Exports carry the raw number and leave formatting to the consumer; the
106
+ // Activity Log row is the only place that renders it as USD.
107
+ cost: run.cost ?? null,
108
+ // Deliberately not filtered through `shouldShowRunPhase`: that predicate
109
+ // decides what is worth a user's attention on screen, and hides the phase
110
+ // of a run whose status already says everything. An export is a record,
111
+ // not a screen — a completed run's last phase is real data, and dropping
112
+ // it here would make the phase of a run that finished indistinguishable
113
+ // from one that never reported a phase at all.
114
+ //
115
+ // Falling back to the label: without it, a phase reported with only a
116
+ // label would export as an empty cell, indistinguishable from no phase.
117
+ // Trimmed rather than nullish-coalesced: the service stores a
118
+ // whitespace-only field as sent — it rejects only a phase blank on
119
+ // *both* — so `??` would let a blank code suppress a real label, which
120
+ // is exactly the empty cell this fallback exists to prevent.
121
+ phase: run.phase_code?.trim() || run.phase_label?.trim() || null,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Page ``GET /v1/{id}/runs`` until complete, then project export rows locally.
127
+ */
128
+ export async function fetchAllActivityLogExportRows(
129
+ automation: Pick<Automation, "id" | "name" | "trigger">,
130
+ conversationBaseUrl?: string,
131
+ ): Promise<AutomationRunExportRow[]> {
132
+ const rows: AutomationRunExportRow[] = [];
133
+ let offset = 0;
134
+ let total = Number.POSITIVE_INFINITY;
135
+
136
+ while (offset < total) {
137
+ const page = await AutomationService.listAutomationRuns(automation.id, {
138
+ limit: EXPORT_PAGE_SIZE,
139
+ offset,
140
+ });
141
+
142
+ for (const run of page.runs) {
143
+ rows.push(
144
+ mapAutomationRunToExportRow(run, automation, conversationBaseUrl),
145
+ );
146
+ }
147
+ total = page.total;
148
+ offset += page.runs.length;
149
+ if (page.runs.length === 0) break;
150
+ }
151
+
152
+ return rows;
153
+ }
154
+
155
+ /**
156
+ * Page the runs list endpoint and download one CSV or JSON file.
157
+ */
158
+ export async function downloadActivityLogExport(options: {
159
+ automation: Pick<Automation, "id" | "name" | "trigger">;
160
+ format: ActivityLogExportFormat;
161
+ conversationBaseUrl?: string;
162
+ }): Promise<void> {
163
+ const { automation, format, conversationBaseUrl } = options;
164
+ const rows = await fetchAllActivityLogExportRows(
165
+ automation,
166
+ conversationBaseUrl,
167
+ );
168
+
169
+ if (format === "json") {
170
+ downloadBlob(
171
+ new Blob(
172
+ [`${JSON.stringify({ runs: rows, total: rows.length }, null, 2)}\n`],
173
+ { type: "application/json" },
174
+ ),
175
+ getActivityLogExportFilename(automation, "json"),
176
+ );
177
+ return;
178
+ }
179
+
180
+ downloadBlob(
181
+ new Blob([serializeActivityLogRowsCsv(rows)], {
182
+ type: "text/csv;charset=utf-8",
183
+ }),
184
+ getActivityLogExportFilename(automation, "csv"),
185
+ );
186
+ }
src/utils/automation-catalog.ts ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AUTOMATION_CATALOG,
3
+ type RecommendedAutomation,
4
+ } from "@openhands/extensions/automations";
5
+ import {
6
+ SKILLS_CATALOG,
7
+ type SkillCatalogEntry,
8
+ } from "@openhands/extensions/skills";
9
+ import type { LucideIcon } from "lucide-react";
10
+ import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
11
+ import { interpolateValues } from "#/manifests/manifest-template";
12
+ import type { Automation } from "#/types/automation";
13
+
14
+ /**
15
+ * Reading a published automation catalog entry.
16
+ *
17
+ * The catalog states each thing once and leaves the rest to be derived, so an
18
+ * entry no longer carries its own launch command or a flat list of required
19
+ * integration ids. This module is the single place that performs those
20
+ * derivations, so the components consuming the catalog stay unaware of how it
21
+ * is shaped.
22
+ */
23
+
24
+ const SKILL_BY_NAME = new Map<string, SkillCatalogEntry>(
25
+ SKILLS_CATALOG.map((skill) => [skill.name, skill]),
26
+ );
27
+
28
+ /**
29
+ * The glyph a card shows in place of its integration logos, or null when it
30
+ * has none to show and the logos speak for it.
31
+ *
32
+ * An entry naming the service it talks to is recognised by that service's
33
+ * logo; one whose identity is the work itself declares a slug instead. The
34
+ * lookup is a guard as much as a mapping — the catalog is published elsewhere,
35
+ * so a slug this host has no artwork for falls back rather than rendering
36
+ * nothing.
37
+ */
38
+ export function getAutomationIcon(
39
+ automation: RecommendedAutomation,
40
+ ): LucideIcon | null {
41
+ const slug = automation.icon;
42
+ if (!slug) return null;
43
+ return (
44
+ (MANIFEST_ICON_BY_SLUG as Record<string, LucideIcon | undefined>)[slug] ??
45
+ null
46
+ );
47
+ }
48
+
49
+ /** Every integration the entry declares, in declaration order. */
50
+ export function getIntegrationIds(automation: RecommendedAutomation): string[] {
51
+ return Object.keys(automation.requires.integrations);
52
+ }
53
+
54
+ /**
55
+ * The integrations that gate this automation. One marked `required: false` can
56
+ * be connected later, during setup, so it is still worth showing on the card
57
+ * but must never stand between the user and a launch.
58
+ */
59
+ export function getRequiredIntegrationIds(
60
+ automation: RecommendedAutomation,
61
+ ): string[] {
62
+ return Object.entries(automation.requires.integrations)
63
+ .filter(([, requirement]) => requirement.required !== false)
64
+ .map(([id]) => id);
65
+ }
66
+
67
+ /**
68
+ * The command that invokes this automation's skill, or null when that skill is
69
+ * invoked by description instead.
70
+ *
71
+ * The command is declared once, in the owning skill's own `triggers`; the
72
+ * catalog names that skill only where it differs from the entry id.
73
+ */
74
+ export function findAutomationCommand(
75
+ automation: Pick<RecommendedAutomation, "id" | "skill">,
76
+ ): string | null {
77
+ const skill = SKILL_BY_NAME.get(automation.skill ?? automation.id);
78
+ return skill?.triggers[0] ?? null;
79
+ }
80
+
81
+ /**
82
+ * What launching an automation card hands to the agent.
83
+ *
84
+ * The resolved command is passed through as-is: API routing (host, auth) is
85
+ * discovered by the agent at runtime from `<RUNTIME_SERVICES>` in the system
86
+ * prompt, and the skills themselves carry the instructions for reading that
87
+ * block. An automation whose skill declares no command has the request spelled
88
+ * out instead. Both are instructions to the agent rather than user-facing UI
89
+ * copy, so they are intentionally in English and not localized.
90
+ */
91
+ export function getAutomationLaunchPrompt(
92
+ automation: RecommendedAutomation,
93
+ ): string {
94
+ return (
95
+ findAutomationCommand(automation) ??
96
+ `Set up the ${automation.name} automation`
97
+ );
98
+ }
99
+
100
+ const CATALOG_ENTRY_BY_ID = new Map<string, RecommendedAutomation>(
101
+ AUTOMATION_CATALOG.map((entry) => [entry.id, entry]),
102
+ );
103
+
104
+ /** Rejects markup the same way the catalog schema's copy rule does. */
105
+ const MARKUP_PATTERN = /<[A-Za-z/!]/;
106
+
107
+ /** Every `{{` an impact phrase opens must be the count placeholder. */
108
+ const FOREIGN_PLACEHOLDER_PATTERN = /\{\{(?!count\}\})/;
109
+
110
+ const COUNT_PLACEHOLDER = "{{count}}";
111
+
112
+ interface AutomationImpactCopy {
113
+ one: string;
114
+ other: string;
115
+ }
116
+
117
+ function isImpactPhrase(value: unknown): value is string {
118
+ return (
119
+ typeof value === "string" &&
120
+ value.length > 0 &&
121
+ !MARKUP_PATTERN.test(value) &&
122
+ !FOREIGN_PLACEHOLDER_PATTERN.test(value)
123
+ );
124
+ }
125
+
126
+ /**
127
+ * The entry's `impact` value statement, or null when it declares none this
128
+ * host can honor. The catalog is published elsewhere, so the field is read
129
+ * off the raw entry and validated here rather than trusted from the package
130
+ * types — which also keeps this module compiling against a pinned package
131
+ * that predates the field. A basis other than `completed-runs` is a counter
132
+ * this host does not know how to compute, so it renders nothing rather than
133
+ * a number the phrase was not written for.
134
+ */
135
+ function readImpactCopy(
136
+ entry: RecommendedAutomation,
137
+ ): AutomationImpactCopy | null {
138
+ const { impact } = entry as unknown as Record<string, unknown>;
139
+ if (typeof impact !== "object" || impact === null) return null;
140
+ const { basis, one, other } = impact as Record<string, unknown>;
141
+ if (basis !== "completed-runs") return null;
142
+ if (!isImpactPhrase(one) || !isImpactPhrase(other)) return null;
143
+ if (!other.includes(COUNT_PLACEHOLDER)) return null;
144
+ return { one, other };
145
+ }
146
+
147
+ /**
148
+ * The catalog entry an installed automation was created from, via the
149
+ * `template` provenance its setup stored — the only join back to the catalog.
150
+ * Null for automations without provenance: imports, agent-built ones, and
151
+ * entries whose setup publishes no version.
152
+ */
153
+ function getAutomationTemplateEntry(
154
+ automation: Automation,
155
+ ): RecommendedAutomation | null {
156
+ const metadata = automation.preset_metadata;
157
+ if (typeof metadata !== "object" || metadata === null) return null;
158
+ const { template } = metadata as Record<string, unknown>;
159
+ if (typeof template !== "object" || template === null) return null;
160
+ const { id } = template as Record<string, unknown>;
161
+ if (typeof id !== "string" || id.length === 0) return null;
162
+ return CATALOG_ENTRY_BY_ID.get(id) ?? null;
163
+ }
164
+
165
+ /**
166
+ * The value statement an automation card shows for its completed runs, or
167
+ * null when there is nothing defensible to say: no template provenance, no
168
+ * honored `impact` declaration, an unknown count (older service with more
169
+ * history than the sample), or a count of zero — absence, never a zero-value
170
+ * claim. Provenance persists across prompt edits, so a reworked automation
171
+ * keeps its template's phrase; the phrases are run-shaped, which keeps them
172
+ * true for as long as the automation runs at all.
173
+ */
174
+ export function resolveAutomationImpactStatement(
175
+ automation: Automation,
176
+ completedTotal: number | null,
177
+ ): string | null {
178
+ if (completedTotal === null || completedTotal < 1) return null;
179
+ const entry = getAutomationTemplateEntry(automation);
180
+ if (!entry) return null;
181
+ const copy = readImpactCopy(entry);
182
+ if (!copy) return null;
183
+ const phrase = completedTotal === 1 ? copy.one : copy.other;
184
+ return interpolateValues(phrase, { count: completedTotal.toLocaleString() });
185
+ }
src/utils/automation-disabled-reason.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { I18nKey } from "#/i18n/declaration";
2
+ import type { Automation } from "#/types/automation";
3
+
4
+ /** Detail `source` values written by the automation service on disable. */
5
+ const MANUAL_DETAIL_SOURCES = new Set(["user"]);
6
+ /** Top-level `disabled_reason` values written for user-initiated disables. */
7
+ const MANUAL_REASON_VALUES = new Set(["manual", "manual_delete"]);
8
+
9
+ /**
10
+ * Whether an automation is inactive *because* of a recorded disablement
11
+ * reason (vs. simply toggled off by an older client path, or an automation
12
+ * service that predates disablement tracking).
13
+ *
14
+ * The automation service overwrites `disabled_reason`/`disabled_detail`/
15
+ * `disabled_at` on every disable and clears them on every re-enable, so when
16
+ * an automation is `enabled === false` and carries a `disabled_reason`, that
17
+ * reason is the latest reason it went inactive.
18
+ */
19
+ export function hasDisablementReason(
20
+ automation: Pick<Automation, "enabled" | "disabled_reason">,
21
+ ): boolean {
22
+ return !automation.enabled && Boolean(automation.disabled_reason);
23
+ }
24
+
25
+ /**
26
+ * Whether the recorded disablement was user-initiated (manual toggle / delete)
27
+ * rather than an automatic pause for failing runs.
28
+ */
29
+ export function isManualDisable(
30
+ automation: Pick<Automation, "disabled_reason" | "disabled_detail">,
31
+ ): boolean {
32
+ const reason = automation.disabled_reason;
33
+ if (reason && MANUAL_REASON_VALUES.has(reason)) return true;
34
+ const source = automation.disabled_detail?.source;
35
+ if (source && MANUAL_DETAIL_SOURCES.has(source)) return true;
36
+ return false;
37
+ }
38
+
39
+ export interface DisablementReasonDisplay {
40
+ /**
41
+ * Full reason text to render. For automatic disables this is the backend's
42
+ * human-readable `disabled_reason` (already phrased for users, e.g.
43
+ * "Paused automatically: auth — ..."). For manual disables the backend
44
+ * stores the opaque "manual" string, so we substitute a localized label.
45
+ */
46
+ text: string;
47
+ }
48
+
49
+ /**
50
+ * Resolve the disablement reason into display text. Returns `null` when the
51
+ * automation is not inactive-for-a-recorded-reason.
52
+ */
53
+ export function getDisablementReasonDisplay(
54
+ automation: Pick<
55
+ Automation,
56
+ "enabled" | "disabled_reason" | "disabled_detail"
57
+ >,
58
+ t: (key: I18nKey) => string,
59
+ ): DisablementReasonDisplay | null {
60
+ if (!hasDisablementReason(automation)) return null;
61
+ if (isManualDisable(automation)) {
62
+ return { text: t(I18nKey.AUTOMATIONS$DETAIL$DISABLED_MANUAL) };
63
+ }
64
+ return { text: automation.disabled_reason as string };
65
+ }
src/utils/automation-export.test.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Automation } from "#/types/automation";
3
+ import {
4
+ AutomationFileValidationError,
5
+ parseAutomationFile,
6
+ serializeAutomation,
7
+ } from "./automation-export";
8
+
9
+ const cronAutomation: Automation = {
10
+ id: "automation-1",
11
+ name: "Daily review",
12
+ trigger: {
13
+ type: "cron",
14
+ schedule: "0 9 * * *",
15
+ schedule_human: "Daily at 09:00",
16
+ },
17
+ enabled: true,
18
+ repository: "openhands/agent-canvas",
19
+ branch: "main",
20
+ model: "fast",
21
+ prompt: "Review open pull requests.",
22
+ plugins: ["github:openhands/extensions"],
23
+ notification: "Post a summary",
24
+ timezone: "America/Los_Angeles",
25
+ created_at: "2026-07-01T00:00:00Z",
26
+ updated_at: "2026-07-02T00:00:00Z",
27
+ last_triggered_at: "2026-07-03T00:00:00Z",
28
+ };
29
+
30
+ describe("automation export files", () => {
31
+ it("round-trips a cron automation without server-assigned fields", () => {
32
+ const exported = serializeAutomation(cronAutomation);
33
+
34
+ expect(exported).toEqual({
35
+ version: 1,
36
+ kind: "automation",
37
+ spec: {
38
+ name: cronAutomation.name,
39
+ trigger: cronAutomation.trigger,
40
+ enabled: true,
41
+ repository: cronAutomation.repository,
42
+ model: cronAutomation.model,
43
+ prompt: cronAutomation.prompt,
44
+ branch: cronAutomation.branch,
45
+ plugins: cronAutomation.plugins,
46
+ notification: cronAutomation.notification,
47
+ timezone: cronAutomation.timezone,
48
+ },
49
+ });
50
+ expect(parseAutomationFile(exported)).toEqual(exported.spec);
51
+ expect(exported.spec).not.toHaveProperty("id");
52
+ expect(exported.spec).not.toHaveProperty("created_at");
53
+ expect(exported.spec).not.toHaveProperty("updated_at");
54
+ expect(exported.spec).not.toHaveProperty("last_triggered_at");
55
+ });
56
+
57
+ it("round-trips event trigger fields", () => {
58
+ const exported = serializeAutomation({
59
+ ...cronAutomation,
60
+ trigger: {
61
+ type: "event",
62
+ source: "github",
63
+ on: ["pull_request.opened", "pull_request.synchronize"],
64
+ filter: "repository.full_name == 'openhands/agent-canvas'",
65
+ },
66
+ timezone: undefined,
67
+ });
68
+
69
+ expect(parseAutomationFile(exported).trigger).toEqual(
70
+ exported.spec.trigger,
71
+ );
72
+ });
73
+
74
+ it("preserves a positive timeout through an export/import round-trip", () => {
75
+ const withTimeout: Automation = { ...cronAutomation, timeout: 900 };
76
+
77
+ const exported = serializeAutomation(withTimeout);
78
+
79
+ expect(parseAutomationFile(exported).timeout).toBe(900);
80
+ });
81
+
82
+ it("reports every malformed field with its path", () => {
83
+ const malformed = {
84
+ version: 2,
85
+ kind: "workflow",
86
+ spec: {
87
+ name: "",
88
+ prompt: null,
89
+ enabled: "yes",
90
+ trigger: { type: "event", source: 42, on: [] },
91
+ plugins: ["github:openhands/extensions", 42],
92
+ model: false,
93
+ timeout: -5,
94
+ },
95
+ };
96
+
97
+ expect(() => parseAutomationFile(malformed)).toThrow(
98
+ AutomationFileValidationError,
99
+ );
100
+
101
+ try {
102
+ parseAutomationFile(malformed);
103
+ } catch (error) {
104
+ expect(error).toBeInstanceOf(AutomationFileValidationError);
105
+ expect((error as AutomationFileValidationError).issues).toEqual(
106
+ expect.arrayContaining([
107
+ "version: expected 1",
108
+ 'kind: expected "automation"',
109
+ "spec.name: expected a non-empty string",
110
+ "spec.prompt: expected a non-empty string",
111
+ "spec.enabled: expected a boolean",
112
+ "spec.trigger.source: expected a string",
113
+ "spec.trigger.source: required for an event trigger",
114
+ "spec.trigger.on: expected a string or a non-empty array of strings",
115
+ "spec.trigger.on: required for an event trigger",
116
+ "spec.plugins: expected an array of non-empty strings",
117
+ "spec.model: expected a non-empty string or null",
118
+ "spec.timeout: expected a positive integer",
119
+ ]),
120
+ );
121
+ }
122
+ });
123
+
124
+ it("rejects non-object files and missing specs", () => {
125
+ expect(() => parseAutomationFile(null)).toThrow(
126
+ "file: expected a JSON object",
127
+ );
128
+ expect(() =>
129
+ parseAutomationFile({ version: 1, kind: "automation" }),
130
+ ).toThrow("spec: expected an object");
131
+ });
132
+ });
src/utils/automation-schedule.ts ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { I18nKey } from "#/i18n/declaration";
2
+
3
+ export type SchedulePresetKind = "daily" | "weekdays" | "weekly";
4
+
5
+ export interface PresetSchedule {
6
+ kind: SchedulePresetKind;
7
+ hour: number;
8
+ minute: number;
9
+ weekday?: number;
10
+ }
11
+
12
+ export interface CustomSchedule {
13
+ kind: "custom";
14
+ raw: string;
15
+ hour?: number;
16
+ minute?: number;
17
+ }
18
+
19
+ export type ParsedSchedule = PresetSchedule | CustomSchedule;
20
+
21
+ const SINGLE_INT = /^(\d+)$/;
22
+
23
+ function parseSingleInt(
24
+ field: string,
25
+ min: number,
26
+ max: number,
27
+ ): number | null {
28
+ const match = field.match(SINGLE_INT);
29
+ if (!match) return null;
30
+ const value = Number(match[1]);
31
+ if (Number.isNaN(value) || value < min || value > max) return null;
32
+ return value;
33
+ }
34
+
35
+ export function parseCronSchedule(
36
+ cron: string | undefined | null,
37
+ ): ParsedSchedule {
38
+ const raw = (cron ?? "").trim();
39
+ if (!raw) return { kind: "custom", raw: "" };
40
+
41
+ const fields = raw.split(/\s+/);
42
+ if (fields.length !== 5) return { kind: "custom", raw };
43
+
44
+ const [minuteField, hourField, domField, monthField, dowField] = fields;
45
+
46
+ const minute = parseSingleInt(minuteField, 0, 59);
47
+ const hour = parseSingleInt(hourField, 0, 23);
48
+
49
+ if (minute === null || hour === null) {
50
+ return { kind: "custom", raw };
51
+ }
52
+ if (domField !== "*" || monthField !== "*") {
53
+ return { kind: "custom", raw, hour, minute };
54
+ }
55
+
56
+ if (dowField === "*" || dowField === "0-6") {
57
+ return { kind: "daily", hour, minute };
58
+ }
59
+ if (dowField === "1-5") {
60
+ return { kind: "weekdays", hour, minute };
61
+ }
62
+ const weekday = parseSingleInt(dowField, 0, 6);
63
+ if (weekday !== null) {
64
+ return { kind: "weekly", hour, minute, weekday };
65
+ }
66
+ return { kind: "custom", raw, hour, minute };
67
+ }
68
+
69
+ export function buildCronSchedule(input: PresetSchedule): string {
70
+ const { kind, hour, minute, weekday } = input;
71
+ switch (kind) {
72
+ case "daily":
73
+ return `${minute} ${hour} * * *`;
74
+ case "weekdays":
75
+ return `${minute} ${hour} * * 1-5`;
76
+ case "weekly":
77
+ return `${minute} ${hour} * * ${weekday ?? 1}`;
78
+ default: {
79
+ const _exhaustive: never = kind;
80
+ return _exhaustive;
81
+ }
82
+ }
83
+ }
84
+
85
+ export function formatTimeOfDay(hour: number, minute: number): string {
86
+ const hh = String(hour).padStart(2, "0");
87
+ const mm = String(minute).padStart(2, "0");
88
+ return `${hh}:${mm}`;
89
+ }
90
+
91
+ export function parseTimeOfDay(
92
+ value: string,
93
+ ): { hour: number; minute: number } | null {
94
+ const match = value.match(/^(\d{1,2}):(\d{2})$/);
95
+ if (!match) return null;
96
+ const hour = Number(match[1]);
97
+ const minute = Number(match[2]);
98
+ if (
99
+ Number.isNaN(hour) ||
100
+ Number.isNaN(minute) ||
101
+ hour < 0 ||
102
+ hour > 23 ||
103
+ minute < 0 ||
104
+ minute > 59
105
+ ) {
106
+ return null;
107
+ }
108
+ return { hour, minute };
109
+ }
110
+
111
+ export function formatEventOn(on: string | string[] | undefined): string {
112
+ if (!on) return "—";
113
+ if (Array.isArray(on)) return on.join(", ");
114
+ return on;
115
+ }
116
+
117
+ /** Example expression shown when the cron field is empty. */
118
+ export const CRON_EXPRESSION_EXAMPLE = "*/10 * * * *";
119
+
120
+ // The service validates with croniter, so this must accept everything croniter
121
+ // does: five fields, optionally plus seconds and then year, or an alias.
122
+ const CRON_ALIASES = new Set([
123
+ "@yearly",
124
+ "@annually",
125
+ "@monthly",
126
+ "@weekly",
127
+ "@daily",
128
+ "@midnight",
129
+ "@hourly",
130
+ ]);
131
+
132
+ const CRON_MIN_FIELDS = 5;
133
+ const CRON_MAX_FIELDS = 7;
134
+
135
+ // Seconds and year are appended after these, so the positions always hold.
136
+ const CRON_FIELD_BOUNDS: readonly (readonly [number, number])[] = [
137
+ [0, 59], // minute
138
+ [0, 23], // hour
139
+ [1, 31], // day of month
140
+ [1, 12], // month
141
+ [0, 7], // day of week — croniter spells Sunday 0 and 7
142
+ ];
143
+
144
+ const DAY_OF_MONTH_INDEX = 2;
145
+ const MONTH_INDEX = 3;
146
+
147
+ // Indexed from 1. February is 29: the 29th does fire, in leap years.
148
+ const MAX_DAYS_IN_MONTH = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
149
+
150
+ const MONTH_NAMES = "JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC";
151
+ const DAY_NAMES = "SUN|MON|TUE|WED|THU|FRI|SAT";
152
+
153
+ // croniter does not bound the step: `*/90` is accepted.
154
+ const NUMERIC_TERM = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/;
155
+
156
+ /** Vocabulary beyond plain numbers, matched but not expanded. */
157
+ const EXTRA_TERM_PATTERNS: readonly (RegExp | null)[] = [
158
+ null, // minute
159
+ null, // hour
160
+ /^(?:\?|L|\d+W)$/i, // `?`, `L`, `15W`
161
+ new RegExp(`^(?:${MONTH_NAMES})(?:-(?:${MONTH_NAMES}))?(?:/\\d+)?$`, "i"),
162
+ // `SUN`, `MON-FRI#2`, `5#3`, `?` — but no `L`, which croniter rejects here.
163
+ new RegExp(
164
+ `^(?:(?:${DAY_NAMES}|\\d+)(?:-(?:${DAY_NAMES}|\\d+))?(?:/\\d+)?(?:#\\d+)?|\\?)$`,
165
+ "i",
166
+ ),
167
+ ];
168
+
169
+ type CronFieldParse =
170
+ | { kind: "invalid" }
171
+ /** Well-formed, but not expanded, so it constrains nothing. */
172
+ | { kind: "unmodelled" }
173
+ | { kind: "values"; values: number[] };
174
+
175
+ function parseCronField(
176
+ field: string,
177
+ [min, max]: readonly [number, number],
178
+ extraPattern: RegExp | null,
179
+ ): CronFieldParse {
180
+ const values = new Set<number>();
181
+
182
+ for (const term of field.split(",")) {
183
+ const match = term.match(NUMERIC_TERM);
184
+ if (!match) {
185
+ if (extraPattern?.test(term)) return { kind: "unmodelled" };
186
+ return { kind: "invalid" };
187
+ }
188
+
189
+ const [, rangePart, stepPart] = match;
190
+ const step = stepPart === undefined ? 1 : Number(stepPart);
191
+ if (step < 1) return { kind: "invalid" };
192
+
193
+ if (rangePart === "*") {
194
+ for (let value = min; value <= max; value += step) values.add(value);
195
+ continue;
196
+ }
197
+
198
+ const [startPart, endPart] = rangePart.split("-");
199
+ const start = parseSingleInt(startPart, min, max);
200
+ if (start === null) return { kind: "invalid" };
201
+ if (endPart === undefined) {
202
+ values.add(start);
203
+ continue;
204
+ }
205
+ const end = parseSingleInt(endPart, min, max);
206
+ if (end === null) return { kind: "invalid" };
207
+ // croniter wraps a reversed range like `5-1`; that wrap is not modelled.
208
+ if (end < start) return { kind: "unmodelled" };
209
+ for (let value = start; value <= end; value += step) values.add(value);
210
+ }
211
+
212
+ return { kind: "values", values: [...values] };
213
+ }
214
+
215
+ export type CronScheduleValidation =
216
+ | { schedule: string }
217
+ | { errorKey: I18nKey };
218
+
219
+ /**
220
+ * Validate a raw cron expression from the edit form — `parseCronSchedule` only
221
+ * classifies against the presets, reporting arbitrary text as `custom`.
222
+ *
223
+ * One-sided by design: rejects only what croniter certainly rejects, and defers
224
+ * on anything it does not model.
225
+ */
226
+ export function validateCronSchedule(raw: string): CronScheduleValidation {
227
+ const schedule = raw.trim();
228
+ if (!schedule) return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID };
229
+
230
+ if (schedule.startsWith("@")) {
231
+ return CRON_ALIASES.has(schedule.toLowerCase())
232
+ ? { schedule }
233
+ : { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID };
234
+ }
235
+
236
+ const fields = schedule.split(/\s+/);
237
+ if (fields.length < CRON_MIN_FIELDS || fields.length > CRON_MAX_FIELDS) {
238
+ return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID };
239
+ }
240
+
241
+ const parsed = CRON_FIELD_BOUNDS.map((bounds, index) =>
242
+ parseCronField(fields[index], bounds, EXTRA_TERM_PATTERNS[index]),
243
+ );
244
+ if (parsed.some((field) => field.kind === "invalid")) {
245
+ return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID };
246
+ }
247
+
248
+ // croniter parses `0 0 31 2 *` and then finds no fire time for it.
249
+ const days = parsed[DAY_OF_MONTH_INDEX];
250
+ const months = parsed[MONTH_INDEX];
251
+ if (
252
+ days.kind === "values" &&
253
+ months.kind === "values" &&
254
+ !months.values.some((month) =>
255
+ days.values.some((day) => day <= MAX_DAYS_IN_MONTH[month]),
256
+ )
257
+ ) {
258
+ return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_UNREACHABLE };
259
+ }
260
+
261
+ return { schedule };
262
+ }
src/utils/automation-stack-section.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ /** Shared bottom inset for stacked home sections (recommended rail, pinned). */
2
+ export const AUTOMATION_STACK_SECTION_BOTTOM_CLASS = "pb-12";
src/utils/automation-timeout.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { I18nKey } from "#/i18n/declaration";
2
+
3
+ /**
4
+ * Per-automation run timeout defaults. The deployment reports the maximum it
5
+ * enforces through capability discovery.
6
+ */
7
+ export const AUTOMATION_TIMEOUT_DEFAULT_SECONDS = 600; // 10 minutes
8
+
9
+ export type AutomationTimeoutValidation =
10
+ | { value: number | null }
11
+ | { errorKey: I18nKey };
12
+
13
+ /**
14
+ * Validate a raw timeout string from the edit form. A blank string means "use
15
+ * the server default" (resolved as `null`). Otherwise the value must be a
16
+ * positive integer. When capability discovery supplies a ceiling, values above
17
+ * it are rejected before the request reaches the service.
18
+ */
19
+ export function validateAutomationTimeout(
20
+ raw: string,
21
+ maxSeconds?: number,
22
+ ): AutomationTimeoutValidation {
23
+ const trimmed = raw.trim();
24
+ if (!trimmed) return { value: null };
25
+
26
+ const seconds = Number(trimmed);
27
+ if (!Number.isInteger(seconds)) {
28
+ return { errorKey: I18nKey.AUTOMATIONS$ERROR_TIMEOUT_INVALID_NUMBER };
29
+ }
30
+ if (seconds <= 0) {
31
+ return { errorKey: I18nKey.AUTOMATIONS$ERROR_TIMEOUT_POSITIVE };
32
+ }
33
+ if (maxSeconds !== undefined && seconds > maxSeconds) {
34
+ return { errorKey: I18nKey.AUTOMATIONS$ERROR_TIMEOUT_MAX_EXCEEDED };
35
+ }
36
+ return { value: seconds };
37
+ }
src/utils/cache-utils.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import type { ActionEvent } from "#/types/agent-server/core/events/action-event";
3
+ import { useModelStore } from "#/stores/model-store";
4
+ import { stripWorkspacePrefix } from "./path-utils";
5
+
6
+ /**
7
+ * Cache invalidation utilities for TanStack Query
8
+ */
9
+
10
+ /**
11
+ * Handle cache invalidation for ActionEvent
12
+ * Invalidates relevant query caches based on the action type
13
+ *
14
+ * @param event - The ActionEvent to process
15
+ * @param conversationId - The conversation ID for cache keys
16
+ * @param queryClient - The TanStack Query client instance
17
+ */
18
+ export const handleActionEventCacheInvalidation = (
19
+ event: ActionEvent,
20
+ conversationId: string,
21
+ queryClient: QueryClient,
22
+ ) => {
23
+ const { action } = event;
24
+
25
+ // Invalidate file_changes cache for file-related actions
26
+ if (
27
+ action.kind === "StrReplaceEditorAction" ||
28
+ action.kind === "FileEditorAction" ||
29
+ action.kind === "ExecuteBashAction"
30
+ ) {
31
+ queryClient.invalidateQueries(
32
+ {
33
+ queryKey: ["file_changes", conversationId],
34
+ },
35
+ { cancelRefetch: false },
36
+ );
37
+ }
38
+
39
+ // Invalidate specific file diff cache for file modifications
40
+ if (
41
+ (action.kind === "StrReplaceEditorAction" ||
42
+ action.kind === "FileEditorAction") &&
43
+ action.path
44
+ ) {
45
+ const strippedPath = stripWorkspacePrefix(action.path);
46
+ queryClient.invalidateQueries({
47
+ queryKey: ["file_diff", conversationId, strippedPath],
48
+ });
49
+ }
50
+
51
+ // When the agent autonomously swaps the LLM via SwitchLLMTool, refresh the
52
+ // conversation so SwitchProfileButton / ChatInputModel pick up the new
53
+ // `llm_model`, and drop any previously-set optimistic profile name (it
54
+ // would now misrepresent the agent's choice).
55
+ if (event.tool_name === "SwitchLLMTool") {
56
+ queryClient.invalidateQueries({
57
+ queryKey: ["user", "conversation", conversationId],
58
+ });
59
+ useModelStore.getState().clearActiveProfile(conversationId);
60
+ }
61
+ };
src/utils/constants.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
2
+
3
+ export const JSON_VIEW_THEME = {
4
+ base00: "transparent", // background
5
+ base01: "var(--cool-grey-900)", // lighter background
6
+ base02: "var(--cool-grey-700)", // selection background
7
+ base03: "var(--cool-grey-600)", // comments, invisibles
8
+ base04: "var(--cool-grey-500)", // dark foreground
9
+ base05: "var(--cool-grey-200)", // default foreground
10
+ base06: "var(--cool-grey-100)", // light foreground
11
+ base07: "#ffffff", // light background
12
+ base08: "#ff5370", // variables, red
13
+ base09: "#f78c6c", // integers, orange
14
+ base0A: "#ffcb6b", // booleans, yellow
15
+ base0B: "#c3e88d", // strings, green
16
+ base0C: "#89ddff", // support, cyan
17
+ base0D: "#82aaff", // functions, blue
18
+ base0E: "#c792ea", // keywords, purple
19
+ base0F: "#ff5370", // deprecated, red
20
+ };
21
+
22
+ export const PRODUCT_URL = {
23
+ PRODUCTION: "https://app.all-hands.dev",
24
+ };
25
+
26
+ export const SETTINGS_FORM = {
27
+ LABEL_CLASSNAME: "text-[11px] font-medium leading-4 tracking-[0.11px]",
28
+ };
29
+
30
+ // Chat input constants
31
+ export const CHAT_INPUT = {
32
+ HEIGHT_THRESHOLD: 100, // Height in pixels when suggestions should be hidden
33
+ };
34
+
35
+ // UI tolerance constants
36
+ export const EPS = 1.5; // px tolerance for "near min" height comparisons
37
+
38
+ /** The /btw slash command — asks a side question via the ask_agent endpoint. */
39
+ export const BTW_COMMAND = "/btw";
40
+
41
+ /** The /model slash command — lists or switches the conversation's LLM profile. */
42
+ export const MODEL_COMMAND = "/model";
43
+
44
+ /** The /goal slash command — drives the agent toward an objective, judging completion each round. */
45
+ export const GOAL_COMMAND = "/goal";
46
+
47
+ /** The /plan slash command — switches the conversation into Plan mode. */
48
+ export const PLAN_COMMAND = "/plan";
49
+
50
+ /** The /code slash command — switches the conversation back to Code mode. */
51
+ export const CODE_COMMAND = "/code";
52
+
53
+ /** Built-in slash commands surfaced in the menu for V1 conversations. */
54
+ export const BUILT_IN_COMMANDS: SlashCommandItem[] = [
55
+ {
56
+ skill: {
57
+ name: "new",
58
+ type: "agentskills",
59
+ source: null,
60
+ content: "Creates a new conversation using the same runtime",
61
+ triggers: ["/new"],
62
+ },
63
+ command: "/new",
64
+ },
65
+ {
66
+ skill: {
67
+ name: "btw",
68
+ type: "agentskills",
69
+ source: null,
70
+ content: "Ask the agent a side question without derailing the main task",
71
+ triggers: [BTW_COMMAND],
72
+ },
73
+ command: BTW_COMMAND,
74
+ },
75
+ {
76
+ skill: {
77
+ name: "model",
78
+ type: "agentskills",
79
+ source: null,
80
+ content:
81
+ "List saved LLM profiles, or switch the conversation LLM profile with /model <name>",
82
+ triggers: [MODEL_COMMAND],
83
+ },
84
+ command: MODEL_COMMAND,
85
+ },
86
+ {
87
+ skill: {
88
+ name: "goal",
89
+ type: "agentskills",
90
+ source: null,
91
+ content:
92
+ "Drive the agent toward an objective until a judge says it's done — /goal <objective> or /goal --max <n> <objective>",
93
+ triggers: [GOAL_COMMAND],
94
+ },
95
+ command: GOAL_COMMAND,
96
+ },
97
+ {
98
+ skill: {
99
+ name: "plan",
100
+ type: "agentskills",
101
+ source: null,
102
+ content:
103
+ "Switch the conversation into Plan mode, or start planning immediately with /plan <task>",
104
+ triggers: [PLAN_COMMAND],
105
+ },
106
+ command: PLAN_COMMAND,
107
+ },
108
+ {
109
+ skill: {
110
+ name: "code",
111
+ type: "agentskills",
112
+ source: null,
113
+ content:
114
+ "Switch the conversation back to Code mode, or resume immediately with /code <task>",
115
+ triggers: [CODE_COMMAND],
116
+ },
117
+ command: CODE_COMMAND,
118
+ },
119
+ ];
120
+
121
+ // Skill content metadata prefixes
122
+ export const METADATA_PREFIXES: readonly string[] = [
123
+ "The following information has been included",
124
+ "It may or may not be relevant",
125
+ "Skill location:",
126
+ "(Use this path to resolve",
127
+ ];
src/utils/conversation-archive-status.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import type { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
2
+
3
+ export function isArchivedSandboxStatus(
4
+ sandboxStatus: SandboxStatus | null | undefined,
5
+ ): boolean {
6
+ return sandboxStatus === "MISSING" || sandboxStatus === "ERROR";
7
+ }
src/utils/conversation-local-storage.ts ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import type {
3
+ ConversationTab,
4
+ ConversationMode,
5
+ } from "#/stores/conversation-store";
6
+ import type { ViewMode } from "#/components/features/files-tab/view-mode";
7
+ import {
8
+ DEFAULT_UNPINNED_OVERVIEW_GIT_PARTS,
9
+ DEFAULT_UNPINNED_OVERVIEW_SECTIONS,
10
+ VALID_CONVERSATION_OVERVIEW_GIT_PARTS,
11
+ VALID_CONVERSATION_OVERVIEW_SECTIONS,
12
+ } from "#/components/features/conversation/conversation-overview-sections";
13
+
14
+ export const LOCAL_STORAGE_KEYS = {
15
+ CONVERSATION_STATE: "conversation-state",
16
+ PENDING_TASK_DRAFT: "pending-task-draft",
17
+ } as const;
18
+
19
+ const CONVERSATION_STATE_UPDATED_EVENT = "conversation-state-updated";
20
+
21
+ type ConversationStateUpdatedDetail = {
22
+ conversationId: string;
23
+ };
24
+
25
+ /**
26
+ * Consolidated conversation state stored in a single localStorage key.
27
+ *
28
+ * NOTE: the right-drawer open/closed state is intentionally *not* persisted
29
+ * here. Users expect the drawer to start closed every time the app is
30
+ * (re)loaded and only stay open when they opened it themselves during the
31
+ * current session. That state lives in the in-memory Zustand
32
+ * `useConversationStore` (`isRightPanelShown` / `hasRightPanelToggled`)
33
+ * which survives in-app navigation but resets on full reloads. Older
34
+ * builds wrote a `rightPanelShown` field into this blob; we accept and
35
+ * silently ignore that field on read so we don't churn old localStorage.
36
+ */
37
+ export interface ConversationState {
38
+ selectedTab: ConversationTab | null;
39
+ unpinnedTabs: string[];
40
+ unpinnedOverviewSections?: string[];
41
+ unpinnedOverviewGitParts?: string[];
42
+ conversationMode: ConversationMode;
43
+ subConversationTaskId: string | null;
44
+ draftMessage: string | null;
45
+ rightPanelShown?: boolean;
46
+ /**
47
+ * Legacy Files-tab Diff/Files toggle preference. Kept for blob
48
+ * compatibility; the Files surface no longer hosts Diff/Commits
49
+ * (those live in the sibling Commits conversation tab).
50
+ */
51
+ filesTabDiffView: boolean | null;
52
+ /** User's persisted choice for the Files tab Rich/Plain content toggle. */
53
+ filesTabContentViewMode: ViewMode;
54
+ /** Whether the left-hand file tree is visible in the Files tab. */
55
+ filesTabTreeVisible?: boolean;
56
+ /** Open file tabs in the Files quick-row (basenames, closable). */
57
+ filesTabOpenPaths?: string[];
58
+ /** Currently selected path among `filesTabOpenPaths`, if any. */
59
+ filesTabSelectedPath?: string | null;
60
+ }
61
+
62
+ const DEFAULT_CONVERSATION_STATE: ConversationState = {
63
+ selectedTab: "files",
64
+ unpinnedTabs: [],
65
+ unpinnedOverviewSections: [...DEFAULT_UNPINNED_OVERVIEW_SECTIONS],
66
+ unpinnedOverviewGitParts: [...DEFAULT_UNPINNED_OVERVIEW_GIT_PARTS],
67
+ conversationMode: "code",
68
+ subConversationTaskId: null,
69
+ draftMessage: null,
70
+ rightPanelShown: false,
71
+ filesTabDiffView: null,
72
+ filesTabContentViewMode: "rich",
73
+ filesTabTreeVisible: true,
74
+ filesTabOpenPaths: [],
75
+ filesTabSelectedPath: null,
76
+ };
77
+
78
+ const VALID_CONVERSATION_TABS: ReadonlySet<ConversationTab> = new Set([
79
+ "files",
80
+ "commits",
81
+ "browser",
82
+ "terminal",
83
+ "planner",
84
+ "tasklist",
85
+ "usage",
86
+ ]);
87
+
88
+ // Tab keys that *used to* exist and were removed during the Files tab
89
+ // refactor. We strip these out of any persisted state on read so that
90
+ // returning users don't keep ghost entries (in `selectedTab` or
91
+ // `unpinnedTabs`) forever — the UI has no way to surface them again to
92
+ // be re-pinned or re-selected. We deliberately use an explicit denylist
93
+ // here rather than a strict "whitelist against VALID_CONVERSATION_TABS"
94
+ // filter because `unpinnedTabs` is typed `string[]` and other parts of
95
+ // the app legitimately store arbitrary tab keys in it (sub-conversation
96
+ // tab ids etc.).
97
+ const REMOVED_CONVERSATION_TABS: ReadonlySet<string> = new Set([
98
+ "editor",
99
+ "served",
100
+ "app",
101
+ "vscode",
102
+ ]);
103
+
104
+ const VALID_VIEW_MODES: ReadonlySet<ViewMode> = new Set(["rich", "plain"]);
105
+
106
+ function sanitizeStoredState(
107
+ stored: Record<string, unknown>,
108
+ ): Partial<ConversationState> {
109
+ let result: Partial<ConversationState> = stored as Partial<ConversationState>;
110
+
111
+ if (
112
+ result.selectedTab != null &&
113
+ (REMOVED_CONVERSATION_TABS.has(result.selectedTab) ||
114
+ (result.selectedTab as string) === "changes" ||
115
+ !VALID_CONVERSATION_TABS.has(result.selectedTab as ConversationTab))
116
+ ) {
117
+ const selectedTab = result.selectedTab as string;
118
+ result = { ...result };
119
+ if (selectedTab === "changes") {
120
+ result.selectedTab = "commits";
121
+ } else {
122
+ delete result.selectedTab;
123
+ }
124
+ }
125
+
126
+ if (result.unpinnedTabs) {
127
+ const filtered = result.unpinnedTabs.filter(
128
+ (tab) => tab !== "changes" && !REMOVED_CONVERSATION_TABS.has(tab),
129
+ );
130
+ if (filtered.length !== result.unpinnedTabs.length) {
131
+ result = { ...result, unpinnedTabs: filtered };
132
+ }
133
+ }
134
+
135
+ if (result.unpinnedOverviewSections) {
136
+ const filtered = result.unpinnedOverviewSections.filter((section) =>
137
+ VALID_CONVERSATION_OVERVIEW_SECTIONS.has(section),
138
+ );
139
+ if (filtered.length !== result.unpinnedOverviewSections.length) {
140
+ result = { ...result, unpinnedOverviewSections: filtered };
141
+ }
142
+ }
143
+
144
+ if (result.unpinnedOverviewGitParts) {
145
+ const filtered = result.unpinnedOverviewGitParts.filter((part) =>
146
+ VALID_CONVERSATION_OVERVIEW_GIT_PARTS.has(part),
147
+ );
148
+ if (filtered.length !== result.unpinnedOverviewGitParts.length) {
149
+ result = { ...result, unpinnedOverviewGitParts: filtered };
150
+ }
151
+ }
152
+
153
+ // Drop a corrupt/manually-edited `filesTabContentViewMode` so it falls
154
+ // back to the typed default ("rich") instead of leaking a junk string
155
+ // into ViewMode-typed consumers. TypeScript can't help us here — the
156
+ // value comes from `JSON.parse(localStorage.getItem(...))` which is
157
+ // unknown at runtime.
158
+ if (
159
+ result.filesTabContentViewMode != null &&
160
+ !VALID_VIEW_MODES.has(result.filesTabContentViewMode)
161
+ ) {
162
+ result = { ...result };
163
+ delete result.filesTabContentViewMode;
164
+ }
165
+
166
+ if (
167
+ result.rightPanelShown != null &&
168
+ typeof result.rightPanelShown !== "boolean"
169
+ ) {
170
+ result = { ...result };
171
+ delete result.rightPanelShown;
172
+ }
173
+
174
+ if (
175
+ result.filesTabTreeVisible != null &&
176
+ typeof result.filesTabTreeVisible !== "boolean"
177
+ ) {
178
+ result = { ...result };
179
+ delete result.filesTabTreeVisible;
180
+ }
181
+
182
+ if (result.filesTabOpenPaths != null) {
183
+ if (!Array.isArray(result.filesTabOpenPaths)) {
184
+ result = { ...result };
185
+ delete result.filesTabOpenPaths;
186
+ } else {
187
+ const filtered = result.filesTabOpenPaths.filter(
188
+ (path): path is string => typeof path === "string" && path.length > 0,
189
+ );
190
+ if (filtered.length !== result.filesTabOpenPaths.length) {
191
+ result = { ...result, filesTabOpenPaths: filtered };
192
+ }
193
+ }
194
+ }
195
+
196
+ if (
197
+ result.filesTabSelectedPath != null &&
198
+ typeof result.filesTabSelectedPath !== "string"
199
+ ) {
200
+ result = { ...result };
201
+ delete result.filesTabSelectedPath;
202
+ }
203
+
204
+ return result;
205
+ }
206
+
207
+ /**
208
+ * Check if a conversation ID is a temporary task ID that should not be persisted.
209
+ * Task IDs have the format "task-{uuid}" and are used during V1 conversation initialization.
210
+ */
211
+ export function isTaskConversationId(conversationId: string): boolean {
212
+ return conversationId.startsWith("task-");
213
+ }
214
+
215
+ /**
216
+ * Whether persistence should be skipped for this conversation id.
217
+ *
218
+ * Skips:
219
+ * - empty string ids (callers outside of a conversation route, e.g.
220
+ * rendered inside a unit test without a NavigationProvider)
221
+ * - "task-..." ids used as placeholders during V1 conversation
222
+ * initialization
223
+ */
224
+ function shouldSkipPersistence(conversationId: string): boolean {
225
+ return conversationId === "" || isTaskConversationId(conversationId);
226
+ }
227
+
228
+ /**
229
+ * Get the full conversation state from localStorage.
230
+ */
231
+ export function getConversationState(
232
+ conversationId: string,
233
+ ): ConversationState {
234
+ if (shouldSkipPersistence(conversationId)) {
235
+ return DEFAULT_CONVERSATION_STATE;
236
+ }
237
+ try {
238
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
239
+ const item = localStorage.getItem(key);
240
+ if (item !== null) {
241
+ return {
242
+ ...DEFAULT_CONVERSATION_STATE,
243
+ ...sanitizeStoredState(JSON.parse(item)),
244
+ };
245
+ }
246
+ return DEFAULT_CONVERSATION_STATE;
247
+ } catch {
248
+ return DEFAULT_CONVERSATION_STATE;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Set the conversation state in localStorage, merging with existing state.
254
+ */
255
+ export function setConversationState(
256
+ conversationId: string,
257
+ updates: Partial<ConversationState>,
258
+ ): void {
259
+ if (shouldSkipPersistence(conversationId)) {
260
+ return;
261
+ }
262
+ try {
263
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
264
+ const currentState = getConversationState(conversationId);
265
+ const newState = { ...currentState, ...updates };
266
+ localStorage.setItem(key, JSON.stringify(newState));
267
+ if (typeof window !== "undefined") {
268
+ window.dispatchEvent(
269
+ new CustomEvent<ConversationStateUpdatedDetail>(
270
+ CONVERSATION_STATE_UPDATED_EVENT,
271
+ { detail: { conversationId } },
272
+ ),
273
+ );
274
+ }
275
+ } catch (err) {
276
+ console.warn("Failed to set conversation localStorage", err);
277
+ }
278
+ }
279
+
280
+ function getPendingTaskDraftKey(taskId: string): string {
281
+ return `${LOCAL_STORAGE_KEYS.PENDING_TASK_DRAFT}-${taskId}`;
282
+ }
283
+
284
+ export function setPendingTaskDraft(
285
+ taskId: string | null | undefined,
286
+ draftMessage: string,
287
+ ): void {
288
+ if (!taskId) return;
289
+ try {
290
+ localStorage.setItem(getPendingTaskDraftKey(taskId), draftMessage);
291
+ } catch (err) {
292
+ console.warn("Failed to store pending task draft", err);
293
+ }
294
+ }
295
+
296
+ export function consumePendingTaskDraft(
297
+ taskId: string | null | undefined,
298
+ ): string | null {
299
+ if (!taskId) return null;
300
+ try {
301
+ const key = getPendingTaskDraftKey(taskId);
302
+ const draft = localStorage.getItem(key);
303
+ localStorage.removeItem(key);
304
+ return draft;
305
+ } catch (err) {
306
+ console.warn("Failed to consume pending task draft", err);
307
+ return null;
308
+ }
309
+ }
310
+
311
+ export function clearConversationLocalStorage(conversationId: string) {
312
+ try {
313
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
314
+ localStorage.removeItem(key);
315
+ if (typeof window !== "undefined") {
316
+ window.dispatchEvent(
317
+ new CustomEvent<ConversationStateUpdatedDetail>(
318
+ CONVERSATION_STATE_UPDATED_EVENT,
319
+ { detail: { conversationId } },
320
+ ),
321
+ );
322
+ }
323
+ } catch (err) {
324
+ console.warn(
325
+ "Failed to clear conversation localStorage",
326
+ conversationId,
327
+ err,
328
+ );
329
+ }
330
+ }
331
+
332
+ /**
333
+ * React hook for conversation-scoped localStorage state.
334
+ * Returns the full state and individual setters for each property.
335
+ *
336
+ * The right-drawer open state is deliberately not represented here —
337
+ * see the note on `ConversationState` for the rationale.
338
+ */
339
+ export function useConversationLocalStorageState(conversationId: string): {
340
+ state: ConversationState;
341
+ setSelectedTab: (tab: ConversationTab | null) => void;
342
+ setUnpinnedTabs: (tabs: string[]) => void;
343
+ setUnpinnedOverviewSections?: (sections: string[]) => void;
344
+ setUnpinnedOverviewGitParts?: (parts: string[]) => void;
345
+ setConversationMode: (mode: ConversationMode) => void;
346
+ setDraftMessage: (message: string | null) => void;
347
+ setRightPanelShown?: (shown: boolean) => void;
348
+ setFilesTabDiffView: (diffView: boolean | null) => void;
349
+ setFilesTabContentViewMode: (mode: ViewMode) => void;
350
+ setFilesTabTreeVisible?: (visible: boolean) => void;
351
+ setFilesTabOpenState?: (
352
+ openPaths: string[],
353
+ selectedPath: string | null,
354
+ ) => void;
355
+ } {
356
+ const [state, setState] = useState<ConversationState>(() =>
357
+ getConversationState(conversationId),
358
+ );
359
+
360
+ useEffect(() => {
361
+ if (typeof window === "undefined") {
362
+ return undefined;
363
+ }
364
+
365
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
366
+
367
+ const syncState = () => {
368
+ setState(getConversationState(conversationId));
369
+ };
370
+
371
+ const handleStorage = (event: StorageEvent) => {
372
+ if (event.key === key) {
373
+ syncState();
374
+ }
375
+ };
376
+
377
+ const handleConversationStateUpdated = (event: Event) => {
378
+ const customEvent = event as CustomEvent<ConversationStateUpdatedDetail>;
379
+ if (customEvent.detail?.conversationId === conversationId) {
380
+ syncState();
381
+ }
382
+ };
383
+
384
+ // Ensure this hook reflects latest state for the current conversation ID.
385
+ syncState();
386
+
387
+ window.addEventListener("storage", handleStorage);
388
+ window.addEventListener(
389
+ CONVERSATION_STATE_UPDATED_EVENT,
390
+ handleConversationStateUpdated,
391
+ );
392
+
393
+ return () => {
394
+ window.removeEventListener("storage", handleStorage);
395
+ window.removeEventListener(
396
+ CONVERSATION_STATE_UPDATED_EVENT,
397
+ handleConversationStateUpdated,
398
+ );
399
+ };
400
+ }, [conversationId]);
401
+
402
+ const updateState = (updates: Partial<ConversationState>) => {
403
+ if (shouldSkipPersistence(conversationId)) {
404
+ // No durable storage for this id (empty / task placeholder), but the
405
+ // hook is still useful as ephemeral in-memory state — update the
406
+ // local React mirror directly so toggles in the UI behave normally
407
+ // until a real conversation id arrives.
408
+ setState((prev) => ({ ...prev, ...updates }));
409
+ return;
410
+ }
411
+ setConversationState(conversationId, updates);
412
+ };
413
+
414
+ return {
415
+ state,
416
+ setSelectedTab: (tab) => updateState({ selectedTab: tab }),
417
+ setUnpinnedTabs: (tabs) => updateState({ unpinnedTabs: tabs }),
418
+ setUnpinnedOverviewSections: (sections) =>
419
+ updateState({ unpinnedOverviewSections: sections }),
420
+ setUnpinnedOverviewGitParts: (parts) =>
421
+ updateState({ unpinnedOverviewGitParts: parts }),
422
+ setConversationMode: (mode) => updateState({ conversationMode: mode }),
423
+ setDraftMessage: (message) => updateState({ draftMessage: message }),
424
+ setRightPanelShown: (shown) => updateState({ rightPanelShown: shown }),
425
+ setFilesTabDiffView: (diffView) =>
426
+ updateState({ filesTabDiffView: diffView }),
427
+ setFilesTabContentViewMode: (mode) =>
428
+ updateState({ filesTabContentViewMode: mode }),
429
+ setFilesTabTreeVisible: (visible) =>
430
+ updateState({ filesTabTreeVisible: visible }),
431
+ setFilesTabOpenState: (openPaths, selectedPath) =>
432
+ updateState({
433
+ filesTabOpenPaths: openPaths,
434
+ filesTabSelectedPath: selectedPath,
435
+ }),
436
+ };
437
+ }
src/utils/conversation-metrics.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ MetricsSnapshot,
3
+ RuntimeConversationInfo,
4
+ RuntimeConversationStats,
5
+ TokenUsage,
6
+ } from "#/api/conversation-service/agent-server-conversation-service.types";
7
+
8
+ /**
9
+ * TypeScript equivalent of the get_combined_metrics method from the Python SDK
10
+ * Combines metrics from all LLM usage IDs in a conversation's stats
11
+ */
12
+ export function combineUsageMetrics(
13
+ stats: RuntimeConversationStats | null | undefined,
14
+ ): MetricsSnapshot {
15
+ if (!stats?.usage_to_metrics) {
16
+ return {
17
+ accumulated_cost: 0,
18
+ max_budget_per_task: null,
19
+ accumulated_token_usage: null,
20
+ };
21
+ }
22
+
23
+ let totalCost = 0;
24
+ let maxBudgetPerTask: number | null = null;
25
+ let combinedTokenUsage: TokenUsage | null = null;
26
+
27
+ // Iterate through all metrics and combine them
28
+ for (const metrics of Object.values(stats.usage_to_metrics)) {
29
+ // Add up costs
30
+ totalCost += metrics.accumulated_cost;
31
+
32
+ // Keep the max budget per task if any is set
33
+ if (maxBudgetPerTask === null && metrics.max_budget_per_task !== null) {
34
+ maxBudgetPerTask = metrics.max_budget_per_task;
35
+ }
36
+
37
+ // Combine token usage
38
+ if (metrics.accumulated_token_usage) {
39
+ if (combinedTokenUsage === null) {
40
+ combinedTokenUsage = { ...metrics.accumulated_token_usage };
41
+ } else {
42
+ combinedTokenUsage = {
43
+ prompt_tokens:
44
+ combinedTokenUsage.prompt_tokens +
45
+ metrics.accumulated_token_usage.prompt_tokens,
46
+ completion_tokens:
47
+ combinedTokenUsage.completion_tokens +
48
+ metrics.accumulated_token_usage.completion_tokens,
49
+ cache_read_tokens:
50
+ combinedTokenUsage.cache_read_tokens +
51
+ metrics.accumulated_token_usage.cache_read_tokens,
52
+ cache_write_tokens:
53
+ combinedTokenUsage.cache_write_tokens +
54
+ metrics.accumulated_token_usage.cache_write_tokens,
55
+ context_window: Math.max(
56
+ combinedTokenUsage.context_window,
57
+ metrics.accumulated_token_usage.context_window,
58
+ ),
59
+ per_turn_token: Math.max(
60
+ combinedTokenUsage.per_turn_token,
61
+ metrics.accumulated_token_usage.per_turn_token,
62
+ ),
63
+ };
64
+ }
65
+ }
66
+ }
67
+
68
+ return {
69
+ accumulated_cost: totalCost,
70
+ max_budget_per_task: maxBudgetPerTask,
71
+ accumulated_token_usage: combinedTokenUsage,
72
+ };
73
+ }
74
+
75
+ export function getCombinedMetrics(
76
+ conversationInfo: RuntimeConversationInfo,
77
+ ): MetricsSnapshot {
78
+ return combineUsageMetrics(conversationInfo.stats);
79
+ }
src/utils/conversation-overview-project-scope.ts ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Automation } from "#/types/automation";
2
+ import type { SkillInfo } from "#/types/settings";
3
+ import {
4
+ getSkillScope,
5
+ SKILL_SCOPE_ORDER,
6
+ type SkillScope,
7
+ } from "#/utils/skill-scope";
8
+
9
+ export const CONVERSATION_OVERVIEW_PROJECT_SCOPE = {
10
+ project: "project",
11
+ all: "all",
12
+ } as const;
13
+
14
+ export type ConversationOverviewProjectScope =
15
+ (typeof CONVERSATION_OVERVIEW_PROJECT_SCOPE)[keyof typeof CONVERSATION_OVERVIEW_PROJECT_SCOPE];
16
+
17
+ export function normalizeRepositoryName(
18
+ repository: string | null | undefined,
19
+ ): string | null {
20
+ if (!repository?.trim()) {
21
+ return null;
22
+ }
23
+ return repository
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/\.git$/, "");
27
+ }
28
+
29
+ export function isAutomationForRepository(
30
+ automation: Pick<Automation, "repository">,
31
+ repository: string | null | undefined,
32
+ ): boolean {
33
+ const target = normalizeRepositoryName(repository);
34
+ const automationRepo = normalizeRepositoryName(automation.repository);
35
+ if (!target || !automationRepo) {
36
+ return false;
37
+ }
38
+ return automationRepo === target;
39
+ }
40
+
41
+ export function isSkillForProject(
42
+ skill: SkillInfo,
43
+ projectDir?: string | null,
44
+ ): boolean {
45
+ return getSkillScope(skill, projectDir) === "project";
46
+ }
47
+
48
+ export function filterAutomationsByProjectScope(
49
+ automations: Automation[],
50
+ scope: ConversationOverviewProjectScope,
51
+ repository: string | null | undefined,
52
+ ): Automation[] {
53
+ if (scope === CONVERSATION_OVERVIEW_PROJECT_SCOPE.all) {
54
+ return automations;
55
+ }
56
+ return automations.filter((automation) =>
57
+ isAutomationForRepository(automation, repository),
58
+ );
59
+ }
60
+
61
+ export function filterSkillsByProjectScope(
62
+ skills: SkillInfo[],
63
+ scope: ConversationOverviewProjectScope,
64
+ projectDir?: string | null,
65
+ ): SkillInfo[] {
66
+ if (scope === CONVERSATION_OVERVIEW_PROJECT_SCOPE.all) {
67
+ return skills;
68
+ }
69
+ return skills.filter((skill) => isSkillForProject(skill, projectDir));
70
+ }
71
+
72
+ export function sortAutomationsByProjectRelevance(
73
+ automations: Automation[],
74
+ repository: string | null | undefined,
75
+ ): Automation[] {
76
+ return [...automations].sort((left, right) => {
77
+ const leftMatch = isAutomationForRepository(left, repository) ? 0 : 1;
78
+ const rightMatch = isAutomationForRepository(right, repository) ? 0 : 1;
79
+ if (leftMatch !== rightMatch) {
80
+ return leftMatch - rightMatch;
81
+ }
82
+ return left.name.localeCompare(right.name);
83
+ });
84
+ }
85
+
86
+ export function sortSkillsByProjectRelevance(
87
+ skills: SkillInfo[],
88
+ projectDir?: string | null,
89
+ ): SkillInfo[] {
90
+ return [...skills].sort((left, right) => {
91
+ const leftScope = getSkillScope(left, projectDir);
92
+ const rightScope = getSkillScope(right, projectDir);
93
+ const leftOrder = SKILL_SCOPE_ORDER.indexOf(leftScope as SkillScope);
94
+ const rightOrder = SKILL_SCOPE_ORDER.indexOf(rightScope as SkillScope);
95
+ if (leftOrder !== rightOrder) {
96
+ return leftOrder - rightOrder;
97
+ }
98
+ return left.name.localeCompare(right.name);
99
+ });
100
+ }
101
+
102
+ export function countAutomationsForRepository(
103
+ automations: readonly Automation[],
104
+ repository: string | null | undefined,
105
+ ): number {
106
+ return automations.filter((automation) =>
107
+ isAutomationForRepository(automation, repository),
108
+ ).length;
109
+ }
110
+
111
+ export function countSkillsForProject(
112
+ skills: readonly SkillInfo[],
113
+ projectDir?: string | null,
114
+ ): number {
115
+ return skills.filter((skill) => isSkillForProject(skill, projectDir)).length;
116
+ }
src/utils/convert-image-to-base-64.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export const convertImageToBase64 = (file: File): Promise<string> =>
2
+ new Promise((resolve, reject) => {
3
+ const reader = new FileReader();
4
+ reader.onloadend = () => {
5
+ resolve(reader.result as string);
6
+ };
7
+ reader.onerror = reject;
8
+ reader.readAsDataURL(file);
9
+ });
src/utils/convert-raw-providers-to-list.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Provider } from "#/types/settings";
2
+
3
+ export const convertRawProvidersToList = (
4
+ raw: Partial<Record<Provider, string | null>> | undefined,
5
+ ): Provider[] => {
6
+ if (!raw) return [];
7
+ const list: Provider[] = [];
8
+ for (const key of Object.keys(raw)) {
9
+ if (key) {
10
+ list.push(key as Provider);
11
+ }
12
+ }
13
+ return list;
14
+ };
src/utils/custom-toast-handlers.tsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { CSSProperties, ReactNode } from "react";
2
+ import { CircleX } from "lucide-react";
3
+ import toast, { ToastOptions } from "react-hot-toast";
4
+ import { OH_STATUS_ERROR_COLOR } from "#/constants/status-colors";
5
+ import { calculateToastDuration } from "./toast-duration";
6
+ import { cn } from "#/utils/utils";
7
+ import i18n from "#/i18n";
8
+ import { I18nKey } from "#/i18n/declaration";
9
+ import {
10
+ isBackendRequestTimeoutMessage,
11
+ isCorsOrNetworkErrorMessage,
12
+ } from "./user-facing-error";
13
+
14
+ // react-hot-toast accepts only CSSProperties via the style option — cannot use className
15
+ const TOAST_STYLE: CSSProperties = {
16
+ background: "var(--oh-color-tertiary)",
17
+ border: "1px solid var(--oh-border-input)",
18
+ color: "#fff",
19
+ borderRadius: "var(--oh-radius)",
20
+ maxWidth: "400px",
21
+ wordBreak: "break-word",
22
+ overflowWrap: "anywhere",
23
+ whiteSpace: "pre-wrap",
24
+ };
25
+
26
+ export const TOAST_OPTIONS: ToastOptions = {
27
+ position: "top-right",
28
+ style: TOAST_STYLE,
29
+ };
30
+
31
+ const ERROR_TOAST_STYLE: CSSProperties = {
32
+ ...TOAST_STYLE,
33
+ color: "var(--oh-muted)",
34
+ };
35
+
36
+ /** Icon + message row; center icon for single-line text, top-align when wrapped. */
37
+ export function ErrorToastContent({ message }: { message: ReactNode }) {
38
+ const contentRef = React.useRef<HTMLSpanElement>(null);
39
+ const [isMultiLine, setIsMultiLine] = React.useState(false);
40
+
41
+ React.useLayoutEffect(() => {
42
+ const content = contentRef.current;
43
+ if (!content) {
44
+ return undefined;
45
+ }
46
+
47
+ const updateIsMultiLine = () => {
48
+ const lineHeight = Number.parseFloat(
49
+ getComputedStyle(content).lineHeight,
50
+ );
51
+ if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
52
+ setIsMultiLine(false);
53
+ return;
54
+ }
55
+
56
+ setIsMultiLine(content.getBoundingClientRect().height > lineHeight * 1.5);
57
+ };
58
+
59
+ updateIsMultiLine();
60
+
61
+ const observer = new ResizeObserver(updateIsMultiLine);
62
+ observer.observe(content);
63
+
64
+ return () => observer.disconnect();
65
+ }, [message]);
66
+
67
+ return (
68
+ <div
69
+ className={cn(
70
+ "flex min-w-0 gap-2",
71
+ isMultiLine ? "items-start" : "items-center",
72
+ )}
73
+ >
74
+ <CircleX
75
+ aria-hidden
76
+ className="h-4 w-4 shrink-0"
77
+ strokeWidth={2}
78
+ style={{ color: OH_STATUS_ERROR_COLOR }}
79
+ />
80
+ <span
81
+ ref={contentRef}
82
+ className="min-w-0 flex-1 text-sm leading-5 [word-break:break-word] [overflow-wrap:anywhere]"
83
+ >
84
+ {message}
85
+ </span>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ export const ERROR_TOAST_OPTIONS: ToastOptions = {
91
+ ...TOAST_OPTIONS,
92
+ icon: null,
93
+ style: ERROR_TOAST_STYLE,
94
+ };
95
+
96
+ export const displayErrorToast = (error: string | null | undefined) => {
97
+ let errorMessage = error || i18n.t(I18nKey.STATUS$ERROR);
98
+ if (isCorsOrNetworkErrorMessage(errorMessage)) {
99
+ errorMessage = i18n.t(I18nKey.ERROR$CORS_OR_NETWORK);
100
+ } else if (isBackendRequestTimeoutMessage(errorMessage)) {
101
+ errorMessage = i18n.t(I18nKey.ERROR$BACKEND_REQUEST_TIMEOUT);
102
+ }
103
+ const duration = calculateToastDuration(errorMessage, 4000);
104
+ toast(<ErrorToastContent message={errorMessage} />, {
105
+ ...ERROR_TOAST_OPTIONS,
106
+ duration,
107
+ });
108
+ };
109
+
110
+ export const displaySuccessToast = (message: string) => {
111
+ const duration = calculateToastDuration(message, 5000);
112
+ toast.success(
113
+ <span className="[word-break:break-word] [overflow-wrap:anywhere]">
114
+ {message}
115
+ </span>,
116
+ { ...TOAST_OPTIONS, duration },
117
+ );
118
+ };
119
+
120
+ export const displaySuccessToastWithLink = (
121
+ message: string,
122
+ linkLabel: string,
123
+ href: string,
124
+ ) => {
125
+ const duration = calculateToastDuration(`${message} ${linkLabel}`, 5000);
126
+ toast.success(
127
+ <span className="[word-break:break-word] [overflow-wrap:anywhere]">
128
+ {message}{" "}
129
+ <a className="underline hover:no-underline" href={href}>
130
+ {linkLabel}
131
+ </a>
132
+ </span>,
133
+ { ...TOAST_OPTIONS, duration },
134
+ );
135
+ };
136
+
137
+ /**
138
+ * Neutral, non-success notice — used when an action completed but the outcome
139
+ * is qualified (e.g. a secret was saved but the active backend can't consume it
140
+ * yet). Renders without the success checkmark so it doesn't read as "all good".
141
+ */
142
+ export const displayWarningToast = (message: string) => {
143
+ const duration = calculateToastDuration(message, 6000);
144
+ toast(
145
+ <span className="[word-break:break-word] [overflow-wrap:anywhere]">
146
+ {message}
147
+ </span>,
148
+ { ...TOAST_OPTIONS, icon: "⚠️", duration },
149
+ );
150
+ };
src/utils/derive-profile-name.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Profile name validation pattern.
3
+ * Profile names: 1-64 chars, must start with alphanumeric, then alphanumerics
4
+ * or '.', '_', '-'. Blocks empty names, path separators, leading dots
5
+ * (hidden files / path traversal), and shell-special characters.
6
+ */
7
+ export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
8
+
9
+ /**
10
+ * Shared profile name validation. Any whitespace (including leading/trailing)
11
+ * makes the value invalid. When `isRequired`, empty values are invalid;
12
+ * otherwise empty is valid.
13
+ */
14
+ export function isProfileNameValid(
15
+ value: string,
16
+ { isRequired = false }: { isRequired?: boolean } = {},
17
+ ): boolean {
18
+ if (value === "") return !isRequired;
19
+ return PROFILE_NAME_PATTERN.test(value);
20
+ }
21
+
22
+ /**
23
+ * Derive a profile name from a model string.
24
+ * Extracts the model portion after the provider prefix (e.g., "openai/gpt-4" -> "gpt-4")
25
+ * and sanitizes it to match the profile name pattern.
26
+ */
27
+ export function deriveProfileNameFromModel(model: string): string {
28
+ // Extract the model name after the last slash (provider/model format)
29
+ const parts = model.split("/");
30
+ const modelName = parts[parts.length - 1] || model;
31
+
32
+ // Sanitize: replace invalid characters with dashes
33
+ let sanitized = modelName
34
+ .replace(/[^A-Za-z0-9._-]/g, "-")
35
+ .replace(/-+/g, "-")
36
+ .replace(/^-+|-+$/g, "");
37
+
38
+ // Ensure it starts with alphanumeric
39
+ if (sanitized && !/^[A-Za-z0-9]/.test(sanitized)) {
40
+ sanitized = `profile-${sanitized}`;
41
+ }
42
+
43
+ // Truncate to 64 characters
44
+ if (sanitized.length > 64) {
45
+ sanitized = sanitized.substring(0, 64).replace(/-+$/, "");
46
+ }
47
+
48
+ return sanitized || "default-profile";
49
+ }
src/utils/dropdown-classes.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "#/utils/utils";
2
+ import { formControlShellClassName } from "#/utils/form-control-classes";
3
+
4
+ /** Snap hover colors instantly — no transition-colors delay on menus/dropdowns. */
5
+ export const dropdownInstantColorClassName = "transition-none";
6
+
7
+ /** 2px vertical gap between rows in a dropdown/context menu list. */
8
+ export const dropdownMenuListGapClassName = "gap-0.5";
9
+
10
+ /** Standard horizontal gap between a row icon and its label. */
11
+ export const dropdownMenuRowGapClassName = "gap-2";
12
+
13
+ /** Flex column shell for a dropdown menu item list. */
14
+ export const dropdownMenuListClassName = cn(
15
+ "flex flex-col",
16
+ dropdownMenuListGapClassName,
17
+ );
18
+
19
+ /** Combobox/select trigger shell with instant hover colors. */
20
+ export const dropdownTriggerShellClassName = cn(
21
+ "bg-tertiary border border-[var(--oh-border-input)] rounded p-2",
22
+ "flex items-center gap-2",
23
+ formControlShellClassName,
24
+ dropdownInstantColorClassName,
25
+ "group w-full gap-2 px-3 text-[var(--oh-muted)] hover:text-white",
26
+ );
27
+
28
+ /** Standard white-label menu row. */
29
+ export const dropdownMenuRowClassName = cn(
30
+ "group flex w-full cursor-pointer items-center rounded px-2 py-2 text-left text-sm font-normal text-white",
31
+ dropdownMenuRowGapClassName,
32
+ "hover:bg-[var(--oh-interactive-hover)] disabled:cursor-not-allowed disabled:opacity-60",
33
+ dropdownInstantColorClassName,
34
+ );
35
+
36
+ /** Menu row using foreground token (context menus). */
37
+ export const dropdownMenuRowForegroundClassName = cn(
38
+ "group flex w-full cursor-pointer items-center rounded px-2 py-2 text-start text-sm font-normal",
39
+ dropdownMenuRowGapClassName,
40
+ "text-[var(--oh-foreground)] hover:bg-[var(--oh-interactive-hover)]",
41
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent",
42
+ dropdownInstantColorClassName,
43
+ );
44
+
45
+ /** Icon inside a menu row — muted until row hover/focus. */
46
+ export const dropdownMenuRowIconClassName = cn(
47
+ "shrink-0 text-[var(--oh-muted)] group-hover:text-white group-focus-visible:text-white",
48
+ dropdownInstantColorClassName,
49
+ );
50
+
51
+ /** Wrapper for SVG/React icon nodes inside a menu row. */
52
+ export const dropdownMenuRowIconWrapperClassName = cn(
53
+ "flex size-4 shrink-0 items-center justify-center [&_svg]:text-current",
54
+ dropdownMenuRowIconClassName,
55
+ );
56
+
57
+ /** Enum/filter dropdown trigger chip. */
58
+ export const dropdownFilterTriggerClassName = cn(
59
+ "inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium",
60
+ "border-[var(--oh-border)] bg-base-secondary text-white",
61
+ "focus-visible:border-white/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/20",
62
+ dropdownInstantColorClassName,
63
+ );
64
+
65
+ /**
66
+ * Scroll cap for the switch-profile menu: four two-line rows
67
+ * (p-2 + leading-5 title + gap-0.5 + leading-4 subtitle) plus list gaps.
68
+ */
69
+ export const switchProfileMenuListScrollClassName = cn(
70
+ dropdownMenuListClassName,
71
+ "overflow-y-auto custom-scrollbar-always",
72
+ "max-h-[13.875rem]",
73
+ );
74
+
75
+ /**
76
+ * Tall sidebar/context menus: cap height to the viewport and scroll with the
77
+ * custom scrollbar when content overflows (no scrollbar when everything fits).
78
+ */
79
+ export const dropdownMenuViewportScrollClassName = cn(
80
+ "overflow-y-auto custom-scrollbar",
81
+ "max-h-[min(60vh,calc(100dvh-5rem))]",
82
+ );
83
+
84
+ /** Footer action row inside a dropdown panel. */
85
+ export const dropdownFooterActionClassName = cn(
86
+ "group flex w-full items-center rounded-md px-2 py-2 text-sm font-normal text-white",
87
+ dropdownMenuRowGapClassName,
88
+ "hover:bg-[var(--oh-interactive-hover)]",
89
+ dropdownInstantColorClassName,
90
+ );
src/utils/enqueue-home-task-pending-message.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils";
2
+ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
3
+ import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
4
+
5
+ /**
6
+ * Shows the user's first message immediately on cloud start-task routes
7
+ * (`/conversations/task-{uuid}`) while the sandbox provisions.
8
+ */
9
+ export async function enqueueHomeTaskPendingMessage(options: {
10
+ conversationId: string;
11
+ text: string;
12
+ images: File[];
13
+ imagesMarkedUploadAsFile: string[];
14
+ }): Promise<void> {
15
+ const { imagesToEmbed } = partitionImagesForUpload(
16
+ options.images,
17
+ options.imagesMarkedUploadAsFile,
18
+ );
19
+ const imageUrls = await Promise.all(
20
+ imagesToEmbed.map((image) => convertImageToBase64(image)),
21
+ );
22
+
23
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
24
+ conversationId: options.conversationId,
25
+ text: options.text,
26
+ content: options.text,
27
+ imageUrls,
28
+ fileUrls: [],
29
+ });
30
+ }