Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/extensions/canvas-extension-module-loader.test.ts +19 -0
- src/extensions/canvas-extension-module-loader.ts +42 -0
- src/hooks/use-acp-credential-form.ts +114 -0
- src/hooks/use-active-agent-profile.ts +32 -0
- src/hooks/use-agent-state.test.tsx +73 -0
- src/hooks/use-agent-state.ts +107 -0
- src/hooks/use-auto-refresh-files-on-edit.ts +151 -0
- src/hooks/use-await-context-compaction.test.ts +178 -0
- src/hooks/use-chat-input-model-state.ts +121 -0
- src/hooks/use-conversation-overview-git-diff-stats.ts +81 -0
- src/hooks/use-debounce.ts +12 -0
- src/hooks/use-device-flow.ts +200 -0
- src/hooks/use-ensure-active-profile.ts +51 -0
- src/hooks/use-handle-plan-click.ts +251 -0
- src/hooks/use-handle-runtime-active.ts +10 -0
- src/hooks/use-handle-ws-events.ts +63 -0
- src/hooks/use-llm-configured.test.tsx +176 -0
- src/hooks/use-migrate-enabled-skills.ts +43 -0
- src/hooks/use-pinned-home-route.ts +113 -0
- src/hooks/use-popover-fixed-placement.ts +56 -0
- src/hooks/use-resizable-drawer-width.ts +126 -0
- src/hooks/use-skill-installs.ts +33 -0
- src/hooks/use-task-list.ts +48 -0
- src/hooks/use-tracking.ts +500 -0
- src/hooks/use-user-providers.ts +17 -0
- src/styles/agent-server-ui-style-scope.ts +117 -0
- src/utils/acp-auth-display.ts +46 -0
- src/utils/acp-command.ts +189 -0
- src/utils/acp-error-codes.ts +29 -0
- src/utils/agent-state-emoji.ts +32 -0
- src/utils/api-error-message.ts +38 -0
- src/utils/automation-activity-log-export.ts +186 -0
- src/utils/automation-catalog.ts +185 -0
- src/utils/automation-disabled-reason.ts +65 -0
- src/utils/automation-export.test.ts +132 -0
- src/utils/automation-schedule.ts +262 -0
- src/utils/automation-stack-section.ts +2 -0
- src/utils/automation-timeout.ts +37 -0
- src/utils/cache-utils.ts +61 -0
- src/utils/constants.ts +127 -0
- src/utils/conversation-archive-status.ts +7 -0
- src/utils/conversation-local-storage.ts +437 -0
- src/utils/conversation-metrics.ts +79 -0
- src/utils/conversation-overview-project-scope.ts +116 -0
- src/utils/convert-image-to-base-64.ts +9 -0
- src/utils/convert-raw-providers-to-list.ts +14 -0
- src/utils/custom-toast-handlers.tsx +150 -0
- src/utils/derive-profile-name.ts +49 -0
- src/utils/dropdown-classes.ts +90 -0
- 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 |
+
}
|