diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 68e6b19cef..bb31621f36 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -30,11 +30,13 @@ export const createDirSyncContext = ( const data = new Proxy({} as State, { get(_, property: keyof State) { if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data) + if (property === "question") return { ...serverSync.session.data.question, global: current()[0].question.global ?? [] } if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data] return current()[0][property] }, }) const set = ((...input: unknown[]) => { + if (input[0] === "question" && input[1] === "global") return (current()[1] as (...args: unknown[]) => unknown)(...input) if (typeof input[0] === "string" && sessionFields.has(input[0])) { return (serverSync.session.set as (...args: unknown[]) => unknown)(...input) } diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 320f4f9b43..07ed070915 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -321,8 +321,9 @@ export async function bootstrapDirectory(input: { retry(() => input.sdk.v2.form.request.list().then((x) => { const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : [])) - const ids = forms.map((question) => question.sessionID) - const grouped = groupBySession(forms) + const global = forms.filter((question) => question.sessionID === "global") + const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global")) + const ids = Object.keys(grouped) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) @@ -330,11 +331,22 @@ export async function bootstrapDirectory(input: { batch(() => { const current = input.session?.data.question ?? input.store.question for (const sessionID of Object.keys(current)) { + if (sessionID === "global") continue if (grouped[sessionID]) continue if (input.session?.get(sessionID)?.directory !== input.directory) continue if (input.session) input.session.set("question", sessionID, []) if (!input.session) input.setStore("question", sessionID, []) } + if (global.length > 0 || input.store.question.global) { + input.setStore( + "question", + "global", + reconcile( + global.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } for (const [sessionID, questions] of Object.entries(grouped)) { const value = reconcile( questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f47e2f55dc..f45aff92fc 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -527,7 +527,8 @@ describe("applyDirectoryEvent", () => { directory: "/tmp", loadLsp() {}, }) - expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.fields[0]?.description).toBe("updated") + const form = store.question[sessionID]?.find((x) => x.id === "q_2") + expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated") applyDirectoryEvent({ event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } }, @@ -540,6 +541,34 @@ describe("applyDirectoryEvent", () => { expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"]) }) + test("tracks global form lifecycles when session content is delegated", () => { + const [store, setStore] = createStore(baseState()) + + applyDirectoryEvent({ + event: { type: "form.created", properties: { form: questionRequest("q_1", "global") } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + sessionContent: false, + }) + + expect(store.question.global?.map((x) => x.id)).toEqual(["q_1"]) + + applyDirectoryEvent({ + event: { type: "form.cancelled", properties: { sessionID: "global", id: "q_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + sessionContent: false, + }) + + expect(store.question.global).toEqual([]) + }) + test("updates vcs branch in store and cache", () => { const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) const [cacheStore, setCacheStore] = createStore({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 74ea2ab028..d35f4c9f84 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -120,7 +120,7 @@ export function applyDirectoryEvent(input: { permission?: State["permission"] }) { const event = input.event - if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return + if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(event)) return const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) switch (event.type) { case "server.instance.disposed": { @@ -413,3 +413,13 @@ export function applyDirectoryEvent(input: { } } } + +export function isGlobalQuestionEvent(event: { type: string; properties?: unknown }) { + if (event.type === "form.created") { + const form = (event.properties as { form?: unknown } | undefined)?.form + return isQuestionForm(form) && form.sessionID === "global" + } + if (event.type !== "form.replied" && event.type !== "form.cancelled") return false + const properties = event.properties as { sessionID?: unknown } | undefined + return properties?.sessionID === "global" +} diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa076..6e11bd75b1 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -25,7 +25,7 @@ import { loadReferencesQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" -import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" +import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } from "./global-sync/event-reducer" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" @@ -375,7 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 - session.apply(event) + if (!isGlobalQuestionEvent(event)) session.apply(event) if (directory === "global") { applyGlobalEvent({ diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index f69939d1c3..b9aba13bb4 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -12,7 +12,15 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" import { ScopedKey } from "@/utils/server-scope" -import { questionAnswer, type QuestionAnswer, type QuestionForm } from "@/utils/question-form" +import { + questionAllowsCustom, + questionAnswer, + questionLabel, + questionMessage, + questionOptions, + type QuestionAnswer, + type QuestionForm, +} from "@/utils/question-form" const cache = new Map() @@ -67,8 +75,8 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const language = useLanguage() const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id) - const questions = createMemo(() => props.request.fields) - const total = createMemo(() => questions().length) + const questions = createMemo(() => (props.request.mode === "form" ? props.request.fields : [])) + const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length)) const cached = cache.get(cacheKey) const [store, setStore] = createStore({ @@ -90,17 +98,23 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( let focusFrame: number | undefined const question = createMemo(() => questions()[store.tab]) - const options = createMemo(() => question()?.options ?? []) - const custom = createMemo(() => question()?.custom !== false) + const options = createMemo(() => questionOptions(question())) + const custom = createMemo(() => questionAllowsCustom(question())) const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => custom() && store.customOn[store.tab] === true) const multi = createMemo(() => question()?.type === "multiselect") const count = createMemo(() => options().length + (custom() ? 1 : 0)) const summary = createMemo(() => { + if (props.request.title) return props.request.title const n = Math.min(store.tab + 1, total()) return language.t("session.question.progress", { current: n, total: total() }) }) + const body = createMemo(() => { + if (props.request.mode === "url") return props.request.title ?? "Open URL request" + return questionLabel(question()) + }) + const message = createMemo(() => questionMessage(props.request)) const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") const customPlaceholder = () => language.t("ui.question.custom.placeholder") @@ -154,11 +168,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i)) const pickFocus = (tab: number = store.tab) => { - const list = questions()[tab]?.options ?? [] - if (questions()[tab]?.custom !== false && store.customOn[tab] === true) return list.length + const list = questionOptions(questions()[tab]) + if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length return Math.max( 0, - list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false), + list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false), ) } @@ -228,7 +242,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( sdk().client.v2.session.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, - formReply: { answer: questionAnswer(props.request.fields, answers) }, + formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) }, }), onMutate: () => { props.onSubmit() @@ -268,7 +282,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const answered = (i: number) => { if ((store.answers[i]?.length ?? 0) > 0) return true - return questions()[i]?.custom !== false && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 + return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 } const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false @@ -386,10 +400,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( if (!opt) return if (multi()) { setStore("editing", false) - toggle(opt.label) + toggle(opt.value) return } - pick(opt.label) + pick(opt.value) } const commitCustom = () => { @@ -535,11 +549,24 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( overflow: store.minimized ? "hidden" : undefined, }} > - {question()?.title} + {body()} + +
{message()}
+
- {language.t("ui.question.singleHint")}}> -
{language.t("ui.question.multiHint")}
+ {language.t("ui.question.singleHint")}}> +
{language.t("ui.question.multiHint")}
+
+ } + > +
Open this URL, complete the request, then submit.
+ + {props.request.mode === "url" ? props.request.url : ""} +
(