@antelopejs/dms-ai 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/constants/frontend-module.d.ts +2 -0
- package/dist/constants/frontend-module.js +6 -1
- package/dist/constants/frontend-module.js.map +1 -1
- package/dist/constants/pages.d.ts +4 -4
- package/dist/constants/pages.js +4 -4
- package/dist/constants/pages.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pages/skills/page.js +0 -2
- package/dist/pages/skills/page.js.map +1 -1
- package/frontend-vue/app/components/ActivityDetail.vue +11 -13
- package/frontend-vue/app/components/ChangesView.vue +56 -49
- package/frontend-vue/app/plugins/ai.client.ts +16 -6
- package/frontend-vue/app/runtime/assistant-activity.ts +38 -21
- package/frontend-vue/app/runtime/sidecar-status.ts +78 -56
- package/frontend-vue/app/views/ChangeSetPanel.vue +13 -18
- package/frontend-vue/app/views/UnavailableState.vue +1 -1
- package/frontend-vue/app/views/composables/useAiData.ts +116 -0
- package/frontend-vue/app/views/composables/useAssistant.ts +8 -2
- package/frontend-vue/app/views/composables/useChangeSetActions.ts +7 -6
- package/frontend-vue/app/views/constants.ts +1 -1
- package/frontend-vue/app/views/utils/change-sets.ts +0 -7
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/assistant-activity.test.ts +69 -0
- package/frontend-vue/tests/chat/markdown.test.ts +1 -0
- package/frontend-vue/tests/chat-panel.test.ts +1 -0
- package/frontend-vue/tests/host-commands.test.ts +1 -0
- package/frontend-vue/tests/navigation.test.ts +1 -0
- package/frontend-vue/tests/palette-assistant.test.ts +1 -0
- package/frontend-vue/tests/panel-states.test.ts +6 -1
- package/frontend-vue/tests/sidecar-guest.test.ts +1 -1
- package/frontend-vue/tests/sidecar-status.test.ts +93 -15
- package/frontend-vue/tests/support/dms-frontend-module.ts +9 -0
- package/frontend-vue/tests/views/api-data-reads.test.ts +445 -0
- package/frontend-vue/tests/views/diff-view.test.ts +1 -0
- package/frontend-vue/tests/views/use-assistant.test.ts +53 -0
- package/frontend-vue/vitest.config.ts +14 -1
- package/package.json +3 -3
- package/sidecar/package.json +2 -1
- package/frontend-vue/app/views/composables/useAiRequest.ts +0 -89
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Ref, readonly, ref, watch } from "vue";
|
|
1
2
|
import { createBackoff, nextDelay, resetBackoff } from "./backoff";
|
|
2
3
|
import {
|
|
3
4
|
SIDECAR_POLL_DELAYS_MS,
|
|
@@ -38,31 +39,52 @@ export interface SidecarStatusController {
|
|
|
38
39
|
dispose: () => void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The read of the sidecar's info the controller follows: `useApiData` in the
|
|
44
|
+
* dashboard, opened with `immediate: false` and polled every `interval`.
|
|
45
|
+
*/
|
|
46
|
+
/** The backend's sidecar info, as far as the tab reads it. */
|
|
47
|
+
export type SidecarInfoPayload = Partial<SidecarInfo>;
|
|
48
|
+
|
|
49
|
+
export interface SidecarInfoRead {
|
|
50
|
+
data: Readonly<Ref<SidecarInfoPayload | null>>;
|
|
51
|
+
error: Readonly<Ref<unknown>>;
|
|
52
|
+
pending: Readonly<Ref<boolean>>;
|
|
53
|
+
/** The first read. */
|
|
54
|
+
execute: () => Promise<void>;
|
|
55
|
+
/** Reads the info again now. */
|
|
56
|
+
refresh: () => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Opens the info read, polled every `interval` ms while it is above 0: the
|
|
61
|
+
* controller raises it while the sidecar comes back, and sets it to 0 once
|
|
62
|
+
* the sidecar answers or gives up.
|
|
63
|
+
*/
|
|
64
|
+
export type OpenSidecarInfoRead = (
|
|
65
|
+
interval: Readonly<Ref<number>>,
|
|
66
|
+
) => SidecarInfoRead;
|
|
67
|
+
|
|
68
|
+
/** The info of the read's last answer, or null when it failed. */
|
|
69
|
+
function readInfo(read: SidecarInfoRead): SidecarInfo | null {
|
|
70
|
+
const body = read.data.value;
|
|
71
|
+
if (read.error.value || !body) return null;
|
|
72
|
+
return {
|
|
73
|
+
isRunning: body.isRunning === true,
|
|
74
|
+
hasGivenUp: body.hasGivenUp === true,
|
|
75
|
+
disabled: body.disabled === true,
|
|
76
|
+
};
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
export function createSidecarStatusController(
|
|
59
|
-
|
|
80
|
+
openRead: OpenSidecarInfoRead,
|
|
60
81
|
): SidecarStatusController {
|
|
61
82
|
const backoff = createBackoff();
|
|
62
83
|
const subscribers = new Set<(status: SidecarStatus) => void>();
|
|
84
|
+
const pollInterval = ref(0);
|
|
63
85
|
let status: SidecarStatus = SIDECAR_STATUS_CONNECTING;
|
|
64
|
-
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
86
|
let disposed = false;
|
|
87
|
+
const read = openRead(readonly(pollInterval));
|
|
66
88
|
|
|
67
89
|
function notify(): void {
|
|
68
90
|
for (const cb of subscribers) cb(status);
|
|
@@ -74,18 +96,8 @@ export function createSidecarStatusController(
|
|
|
74
96
|
notify();
|
|
75
97
|
}
|
|
76
98
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
const delay = nextDelay(backoff, SIDECAR_POLL_DELAYS_MS);
|
|
80
|
-
pollTimer = setTimeout(() => {
|
|
81
|
-
pollTimer = null;
|
|
82
|
-
void poll();
|
|
83
|
-
}, delay);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function poll(): Promise<void> {
|
|
87
|
-
if (disposed) return;
|
|
88
|
-
handleInfo(await fetchInfo(fetchDetails));
|
|
99
|
+
function keepPolling(): void {
|
|
100
|
+
pollInterval.value = nextDelay(backoff, SIDECAR_POLL_DELAYS_MS);
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
function handleInfo(info: SidecarInfo | null): void {
|
|
@@ -93,31 +105,56 @@ export function createSidecarStatusController(
|
|
|
93
105
|
// Probe itself failed (backend momentarily unreachable): keep waiting.
|
|
94
106
|
if (info === null) {
|
|
95
107
|
setStatus(SIDECAR_STATUS_REVIVING);
|
|
96
|
-
|
|
108
|
+
keepPolling();
|
|
97
109
|
return;
|
|
98
110
|
}
|
|
99
111
|
if (info.hasGivenUp) {
|
|
112
|
+
pollInterval.value = 0;
|
|
100
113
|
setStatus(SIDECAR_STATUS_UNAVAILABLE);
|
|
101
114
|
return;
|
|
102
115
|
}
|
|
103
116
|
if (info.isRunning) {
|
|
104
117
|
resetBackoff(backoff);
|
|
118
|
+
pollInterval.value = 0;
|
|
105
119
|
setStatus(SIDECAR_STATUS_CONNECTED);
|
|
106
120
|
return;
|
|
107
121
|
}
|
|
108
122
|
// Down but not terminal — the probe already asked the backend to respawn;
|
|
109
123
|
// keep polling until it reports the sidecar running.
|
|
110
124
|
setStatus(SIDECAR_STATUS_REVIVING);
|
|
111
|
-
|
|
125
|
+
keepPolling();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Each answer, a probe's or the polling's, moves the status.
|
|
129
|
+
const stopFollowing = watch(
|
|
130
|
+
read.pending,
|
|
131
|
+
(isPending, wasPending) => {
|
|
132
|
+
if (wasPending && !isPending) handleInfo(readInfo(read));
|
|
133
|
+
},
|
|
134
|
+
{ flush: "sync" },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
function dispose(): void {
|
|
138
|
+
disposed = true;
|
|
139
|
+
pollInterval.value = 0;
|
|
140
|
+
stopFollowing();
|
|
141
|
+
subscribers.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function probeAgain(): void {
|
|
145
|
+
if (disposed) return;
|
|
146
|
+
resetBackoff(backoff);
|
|
147
|
+
setStatus(SIDECAR_STATUS_REVIVING);
|
|
148
|
+
void read.refresh();
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
return {
|
|
115
152
|
init: async (): Promise<boolean> => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (info.disabled) return
|
|
119
|
-
|
|
120
|
-
return
|
|
153
|
+
await read.execute();
|
|
154
|
+
const info = readInfo(read);
|
|
155
|
+
if (info !== null && !info.disabled) return true;
|
|
156
|
+
dispose();
|
|
157
|
+
return false;
|
|
121
158
|
},
|
|
122
159
|
getStatus: () => status,
|
|
123
160
|
subscribe: (cb) => {
|
|
@@ -128,29 +165,14 @@ export function createSidecarStatusController(
|
|
|
128
165
|
};
|
|
129
166
|
},
|
|
130
167
|
reportChannelDown: () => {
|
|
131
|
-
if (disposed) return;
|
|
132
168
|
if (status !== SIDECAR_STATUS_CONNECTED) return;
|
|
133
169
|
// Surface the drop right away (amber dot + spinner) instead of waiting on
|
|
134
170
|
// the re-probe: that probe awaits a respawn and can take a second or two,
|
|
135
|
-
// during which the icon would otherwise look healthy.
|
|
136
|
-
// to connected once the sidecar answers again.
|
|
137
|
-
|
|
138
|
-
setStatus(SIDECAR_STATUS_REVIVING);
|
|
139
|
-
void poll();
|
|
140
|
-
},
|
|
141
|
-
retry: () => {
|
|
142
|
-
if (disposed) return;
|
|
143
|
-
resetBackoff(backoff);
|
|
144
|
-
setStatus(SIDECAR_STATUS_REVIVING);
|
|
145
|
-
void poll();
|
|
146
|
-
},
|
|
147
|
-
dispose: () => {
|
|
148
|
-
disposed = true;
|
|
149
|
-
if (pollTimer !== null) {
|
|
150
|
-
clearTimeout(pollTimer);
|
|
151
|
-
pollTimer = null;
|
|
152
|
-
}
|
|
153
|
-
subscribers.clear();
|
|
171
|
+
// during which the icon would otherwise look healthy. The answer flips
|
|
172
|
+
// back to connected once the sidecar answers again.
|
|
173
|
+
probeAgain();
|
|
154
174
|
},
|
|
175
|
+
retry: probeAgain,
|
|
176
|
+
dispose,
|
|
155
177
|
};
|
|
156
178
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed
|
|
2
|
+
import { computed } from "vue";
|
|
3
3
|
import DiffView from "./DiffView.vue";
|
|
4
4
|
import UnavailableState from "./UnavailableState.vue";
|
|
5
|
-
import {
|
|
5
|
+
import { useAiData } from "./composables/useAiData";
|
|
6
6
|
import { useAssistant } from "./composables/useAssistant";
|
|
7
7
|
import { useChangeSetActions } from "./composables/useChangeSetActions";
|
|
8
8
|
import { useViewFormat } from "./composables/useViewFormat";
|
|
@@ -20,28 +20,23 @@ interface Props {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const props = defineProps<Props>();
|
|
23
|
-
const emit = defineEmits<{ changed: [] }>();
|
|
24
23
|
|
|
25
24
|
const { t } = useI18n();
|
|
26
|
-
const { $authFetch } = useAuthFetch();
|
|
27
25
|
const { moment } = useViewFormat();
|
|
28
26
|
const assistant = useAssistant();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
// A set picked again shows what was read before and reads again: it may have
|
|
28
|
+
// been undone or redone since.
|
|
29
|
+
const detail = useAiData<ChangeSetDetail>(
|
|
30
|
+
() => `${AI_ROUTES.changes}/${encodeURIComponent(props.changeSetId)}`,
|
|
31
|
+
);
|
|
32
|
+
// Once a set is undone or redone, the page reads its data again: this panel,
|
|
33
|
+
// the list and the assistant's status.
|
|
34
|
+
const actions = useChangeSetActions();
|
|
34
35
|
|
|
35
|
-
function load(): Promise<
|
|
36
|
-
return detail.
|
|
37
|
-
$authFetch<ChangeSetDetail>(
|
|
38
|
-
`${AI_ROUTES.changes}/${encodeURIComponent(props.changeSetId)}`,
|
|
39
|
-
),
|
|
40
|
-
);
|
|
36
|
+
function load(): Promise<void> {
|
|
37
|
+
return detail.refresh();
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
watch(() => props.changeSetId, load, { immediate: true });
|
|
44
|
-
|
|
45
40
|
const changeSet = computed<ChangeSetSummary | null>(
|
|
46
41
|
() => detail.data.value?.changeSet ?? null,
|
|
47
42
|
);
|
|
@@ -128,7 +123,7 @@ function openPage(): void {
|
|
|
128
123
|
<USkeleton class="mt-4 h-24 w-full" />
|
|
129
124
|
<USkeleton class="h-40 w-full" />
|
|
130
125
|
</div>
|
|
131
|
-
<article v-else :aria-busy="detail.
|
|
126
|
+
<article v-else :aria-busy="detail.pending.value">
|
|
132
127
|
<header class="flex min-h-16 flex-wrap items-start gap-3 px-5 py-3.5">
|
|
133
128
|
<div class="min-w-0 flex-1">
|
|
134
129
|
<p
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from "vue";
|
|
3
|
-
import type { RequestFailure } from "./composables/
|
|
3
|
+
import type { RequestFailure } from "./composables/useAiData";
|
|
4
4
|
import { useViewFormat } from "./composables/useViewFormat";
|
|
5
5
|
|
|
6
6
|
type UnavailableStateSize = "sm" | "md" | "lg";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type MaybeRefOrGetter } from "vue";
|
|
2
|
+
import type {
|
|
3
|
+
ApiDataRequest,
|
|
4
|
+
ApiDataResult,
|
|
5
|
+
ApiPagedDataHandler,
|
|
6
|
+
ApiPagedDataResult,
|
|
7
|
+
UseApiDataOptions,
|
|
8
|
+
} from "#dms-core/app/composables/auth/useApiData";
|
|
9
|
+
import type {
|
|
10
|
+
DmsAsyncDataKey,
|
|
11
|
+
UseDmsPagedDataOptions,
|
|
12
|
+
} from "#dms/frontend-module";
|
|
13
|
+
import { HTTP_SERVICE_UNAVAILABLE } from "../constants";
|
|
14
|
+
|
|
15
|
+
/** Why a request failed: the sidecar is down (503), or anything else. */
|
|
16
|
+
export type RequestFailure = "unavailable" | "error";
|
|
17
|
+
|
|
18
|
+
/** Why a read failed, and the server's message for it. */
|
|
19
|
+
interface FailureDescription {
|
|
20
|
+
/** Why the last read failed; none while a retry is in flight. */
|
|
21
|
+
failure: ComputedRef<RequestFailure | null>;
|
|
22
|
+
/** The server's message for the failure. */
|
|
23
|
+
message: ComputedRef<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A view's read, and why it failed. */
|
|
27
|
+
export type AiData<T> = ApiDataResult<T> & FailureDescription;
|
|
28
|
+
|
|
29
|
+
/** A view's list read page by page, and why it failed. */
|
|
30
|
+
export type AiPagedData<TPage, TCursor> = ApiPagedDataResult<TPage, TCursor> &
|
|
31
|
+
FailureDescription;
|
|
32
|
+
|
|
33
|
+
/** What a failure description needs of a read. */
|
|
34
|
+
interface FailingRead {
|
|
35
|
+
error: { value: unknown };
|
|
36
|
+
status: { value: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FetchErrorResponse {
|
|
40
|
+
status?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FetchErrorBody {
|
|
44
|
+
message?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface FetchErrorLike {
|
|
48
|
+
status?: number;
|
|
49
|
+
statusCode?: number;
|
|
50
|
+
response?: FetchErrorResponse;
|
|
51
|
+
data?: FetchErrorBody;
|
|
52
|
+
message?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The HTTP status of a `$authFetch` rejection, if it got an answer. */
|
|
56
|
+
function errorStatus(error: unknown): number | undefined {
|
|
57
|
+
const candidate = error as FetchErrorLike | null;
|
|
58
|
+
return (
|
|
59
|
+
candidate?.status ?? candidate?.statusCode ?? candidate?.response?.status
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The server's `{ message }`, else the error's own text. */
|
|
64
|
+
export function errorMessage(error: unknown): string {
|
|
65
|
+
const candidate = error as FetchErrorLike | null;
|
|
66
|
+
const serverMessage = candidate?.data?.message;
|
|
67
|
+
if (typeof serverMessage === "string") return serverMessage;
|
|
68
|
+
return candidate?.message ?? String(error);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function classifyFailure(error: unknown): RequestFailure {
|
|
72
|
+
return errorStatus(error) === HTTP_SERVICE_UNAVAILABLE
|
|
73
|
+
? "unavailable"
|
|
74
|
+
: "error";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A view's read of the assistant's data, with the page (`useApiData`): a
|
|
79
|
+
* server render reads it and hands it to the browser. Its failure says
|
|
80
|
+
* whether the assistant isn't running (503) rather than an error.
|
|
81
|
+
*/
|
|
82
|
+
function describeFailure(result: FailingRead): FailureDescription {
|
|
83
|
+
return {
|
|
84
|
+
failure: computed(() =>
|
|
85
|
+
result.error.value && result.status.value !== "pending"
|
|
86
|
+
? classifyFailure(result.error.value)
|
|
87
|
+
: null,
|
|
88
|
+
),
|
|
89
|
+
message: computed(() =>
|
|
90
|
+
result.error.value ? errorMessage(result.error.value) : "",
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useAiData<T>(
|
|
96
|
+
url: ApiDataRequest,
|
|
97
|
+
options?: UseApiDataOptions<T>,
|
|
98
|
+
): AiData<T> {
|
|
99
|
+
const result = useApiData<T>(url, options);
|
|
100
|
+
return Object.assign(result, describeFailure(result));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A view's list read page by page ("Load more"), with the page
|
|
105
|
+
* (`useApiPagedData`): a server render reads the first page, and the page's
|
|
106
|
+
* refreshes read every page loaded again. Its failure says whether the
|
|
107
|
+
* assistant isn't running (503) rather than an error.
|
|
108
|
+
*/
|
|
109
|
+
export function useAiPagedData<TPage, TCursor>(
|
|
110
|
+
key: MaybeRefOrGetter<DmsAsyncDataKey>,
|
|
111
|
+
handler: ApiPagedDataHandler<TPage, TCursor>,
|
|
112
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
113
|
+
): AiPagedData<TPage, TCursor> {
|
|
114
|
+
const result = useApiPagedData<TPage, TCursor>(key, handler, options);
|
|
115
|
+
return Object.assign(result, describeFailure(result));
|
|
116
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { inject } from "vue";
|
|
1
|
+
import { getCurrentInstance, inject, onMounted, ref } from "vue";
|
|
2
2
|
import { useDmsRouter } from "#dms/frontend-module";
|
|
3
3
|
import { ASSISTANT_SESSION_KEY } from "../../runtime/assistant-session";
|
|
4
4
|
|
|
@@ -16,13 +16,19 @@ export interface AssistantActions {
|
|
|
16
16
|
* The tab's assistant session as the workspace views use it. Until it runs
|
|
17
17
|
* (its first probe pending) and without one (production, sidecar disabled) the
|
|
18
18
|
* actions do nothing and `isAvailable` hides the buttons that need them.
|
|
19
|
+
*
|
|
20
|
+
* A view only offers them once mounted: the server never has a session, and a
|
|
21
|
+
* probe that answered before the view hydrated would otherwise make the
|
|
22
|
+
* browser render buttons the server's HTML lacks.
|
|
19
23
|
*/
|
|
20
24
|
export function useAssistant(): AssistantActions {
|
|
21
25
|
const session = inject(ASSISTANT_SESSION_KEY, null);
|
|
22
26
|
const router = useDmsRouter();
|
|
27
|
+
const isMounted = ref(!getCurrentInstance());
|
|
28
|
+
if (!isMounted.value) onMounted(() => (isMounted.value = true));
|
|
23
29
|
return {
|
|
24
30
|
get isAvailable() {
|
|
25
|
-
return (session?.value ?? null) !== null;
|
|
31
|
+
return isMounted.value && (session?.value ?? null) !== null;
|
|
26
32
|
},
|
|
27
33
|
openPanel: () => session?.value?.panel.open(),
|
|
28
34
|
openConversation: (conversationId) =>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ref, type Ref } from "vue";
|
|
2
2
|
import { AI_ROUTES } from "../constants";
|
|
3
3
|
import type { ChangeSetSummary } from "../types";
|
|
4
|
-
import { classifyFailure, errorMessage } from "./
|
|
4
|
+
import { classifyFailure, errorMessage } from "./useAiData";
|
|
5
5
|
|
|
6
6
|
export interface ChangeSetActions {
|
|
7
7
|
isBusy: Ref<boolean>;
|
|
@@ -21,10 +21,11 @@ function changeSetPath(changeSetId: string, action: string): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Undo and redo of a change set, each confirmed by a toast;
|
|
25
|
-
* once the sidecar has restored the files
|
|
24
|
+
* Undo and redo of a change set, each confirmed by a toast; the page is read
|
|
25
|
+
* again once the sidecar has restored the files, and the action stays busy
|
|
26
|
+
* until it is: the set shows its new state before it can be acted on again.
|
|
26
27
|
*/
|
|
27
|
-
export function useChangeSetActions(
|
|
28
|
+
export function useChangeSetActions(): ChangeSetActions {
|
|
28
29
|
const { t } = useI18n();
|
|
29
30
|
const toast = useToast();
|
|
30
31
|
const { $authFetch } = useAuthFetch();
|
|
@@ -56,7 +57,7 @@ export function useChangeSetActions(onChanged: () => void): ChangeSetActions {
|
|
|
56
57
|
color: AI_COLOR,
|
|
57
58
|
icon: "i-ph-arrow-clockwise",
|
|
58
59
|
});
|
|
59
|
-
|
|
60
|
+
await refreshPageBlocks();
|
|
60
61
|
} catch (error) {
|
|
61
62
|
failureToast(error);
|
|
62
63
|
} finally {
|
|
@@ -88,7 +89,7 @@ export function useChangeSetActions(onChanged: () => void): ChangeSetActions {
|
|
|
88
89
|
},
|
|
89
90
|
],
|
|
90
91
|
});
|
|
91
|
-
|
|
92
|
+
await refreshPageBlocks();
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
async function undo(changeSet: ChangeSetSummary): Promise<void> {
|
|
@@ -4,7 +4,7 @@ import type { Tone } from "#dms-ui/app/types/tone";
|
|
|
4
4
|
export const AI_ROUTES = {
|
|
5
5
|
status: "/ai/status",
|
|
6
6
|
changes: "/ai/changes",
|
|
7
|
-
changeUndoConfirm: "/ai/changes/{id}/undo-confirm",
|
|
7
|
+
changeUndoConfirm: "/ai/changes/{{row.id}}/undo-confirm",
|
|
8
8
|
activity: "/ai/activity",
|
|
9
9
|
} as const;
|
|
10
10
|
|
|
@@ -26,13 +26,6 @@ export function matchesSearch(
|
|
|
26
26
|
return haystack.some((text) => text.toLowerCase().includes(needle));
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** The `?set=<id>` deep link of the page, if any. */
|
|
30
|
-
export function readSelectedSet(): string | undefined {
|
|
31
|
-
if (typeof window === "undefined") return undefined;
|
|
32
|
-
const params = new URLSearchParams(window.location.search);
|
|
33
|
-
return params.get(CHANGE_SET_QUERY_KEY) ?? undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
29
|
/**
|
|
37
30
|
* Keeps the selected set in the URL without a visit, so a reload or a shared
|
|
38
31
|
* link opens it again.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"packageManager": "pnpm@10.6.5",
|
|
8
8
|
"engines": {
|
|
9
|
-
"@antelopejs/dms-frontend": ">=0.
|
|
9
|
+
"@antelopejs/dms-frontend": ">=0.6.0 <0.7.0"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" build",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { nextTick, ref } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
type AssistantStatusPayload,
|
|
5
|
+
createAssistantActivity,
|
|
6
|
+
} from "../app/runtime/assistant-activity";
|
|
7
|
+
import type { ChatTransport } from "../app/runtime/chat-transport";
|
|
8
|
+
|
|
9
|
+
type Listener = (msg: object) => void;
|
|
10
|
+
|
|
11
|
+
function fakeChat() {
|
|
12
|
+
const listeners = new Set<Listener>();
|
|
13
|
+
const chat = {
|
|
14
|
+
onMessage: (listener: Listener) => {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => listeners.delete(listener);
|
|
17
|
+
},
|
|
18
|
+
} as unknown as ChatTransport;
|
|
19
|
+
return { chat, emit: (msg: object) => listeners.forEach((l) => l(msg)) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("assistant activity", () => {
|
|
23
|
+
it("polls the status through the read's interval while it is useful, and stops on teardown", async () => {
|
|
24
|
+
const data = ref<AssistantStatusPayload | null>(null);
|
|
25
|
+
let interval: () => number = () => -1;
|
|
26
|
+
const isPanelOpen = ref(false);
|
|
27
|
+
const { chat } = fakeChat();
|
|
28
|
+
const activity = createAssistantActivity({
|
|
29
|
+
readStatus: (given) => {
|
|
30
|
+
interval = given;
|
|
31
|
+
return { data, refresh: async () => undefined };
|
|
32
|
+
},
|
|
33
|
+
chat,
|
|
34
|
+
shouldPoll: () => !isPanelOpen.value,
|
|
35
|
+
});
|
|
36
|
+
expect(interval()).toBe(15_000);
|
|
37
|
+
isPanelOpen.value = true;
|
|
38
|
+
expect(interval()).toBe(0);
|
|
39
|
+
isPanelOpen.value = false;
|
|
40
|
+
activity.stop();
|
|
41
|
+
expect(interval()).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("takes its counts from each status answer and from the stream's lists", async () => {
|
|
45
|
+
const data = ref<AssistantStatusPayload | null>(null);
|
|
46
|
+
const { chat, emit } = fakeChat();
|
|
47
|
+
const activity = createAssistantActivity({
|
|
48
|
+
readStatus: () => ({ data, refresh: async () => undefined }),
|
|
49
|
+
chat,
|
|
50
|
+
shouldPoll: () => true,
|
|
51
|
+
});
|
|
52
|
+
data.value = { pendingApprovals: 2, workingConversations: 0 };
|
|
53
|
+
await nextTick();
|
|
54
|
+
expect(activity.pendingApprovals.value).toBe(2);
|
|
55
|
+
expect(activity.isBusy.value).toBe(true);
|
|
56
|
+
emit({ type: "conversation_list", conversations: [{ isRunning: true }] });
|
|
57
|
+
expect(activity.pendingApprovals.value).toBe(0);
|
|
58
|
+
expect(activity.workingConversations.value).toBe(1);
|
|
59
|
+
data.value = {
|
|
60
|
+
pendingApprovals: 0,
|
|
61
|
+
workingConversations: 0,
|
|
62
|
+
lastError: "boom",
|
|
63
|
+
};
|
|
64
|
+
await nextTick();
|
|
65
|
+
expect(activity.workingConversations.value).toBe(0);
|
|
66
|
+
expect(activity.lastError.value).toBe("boom");
|
|
67
|
+
activity.stop();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
1
2
|
import { flushPromises } from "@vue/test-utils";
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
4
|
import { nextTick } from "vue";
|
|
@@ -334,6 +335,10 @@ describe("tool rows and change sets", () => {
|
|
|
334
335
|
|
|
335
336
|
describe("the approvals dock", () => {
|
|
336
337
|
it("previews a diff with line numbers and allows a scope with ↵", async () => {
|
|
338
|
+
// The countdown reads the clock: frozen at the request's creation, it
|
|
339
|
+
// shows the full grace period however slow the run is.
|
|
340
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
341
|
+
vi.setSystemTime(DIFF_REQUEST.createdAtMs);
|
|
337
342
|
const harness = createHarness();
|
|
338
343
|
const panel = await openPanel(harness);
|
|
339
344
|
deliver(harness, DIFF_REQUEST);
|
|
@@ -342,7 +347,7 @@ describe("the approvals dock", () => {
|
|
|
342
347
|
const dock = panel.find(".cb-approve");
|
|
343
348
|
expect(dock.attributes("role")).toBe("alertdialog");
|
|
344
349
|
expect(dock.text()).toContain("Needs your approval · 1 of 2");
|
|
345
|
-
expect(dock.text()).
|
|
350
|
+
expect(dock.text()).toContain("denies in 4:12");
|
|
346
351
|
expect(panel.find(".perm.is-focused .perm__title").text()).toBe(
|
|
347
352
|
"Edit customer.form.ts",
|
|
348
353
|
);
|