feat(ai): open the Compare & Deploy page from chat with item preselection (#10232)

* feat(ai): open the Compare & Deploy page from chat with item preselection

* fix(ai): label the compare link card outside sessions

* fix(ai): scope untracked-chat compare links to explicit items

* style: drop narration comment on compare mask precedence

* fix(ai): match compare items mask against parked live-draft paths

* fix(ai): land maskless-mode compare on the view holding the masked drafts

* fix(ai): honor explicit fork mode over the draft-mask heuristic

* docs(ai): describe mask-aware compare mode auto-pick

* fix(ai): match legacy app fork diffs under their identity mask key
This commit is contained in:
Guilhem
2026-07-21 14:58:18 +02:00
committed by GitHub
parent 28966bdbf1
commit 572d69e5ae
16 changed files with 442 additions and 47 deletions
+32
View File
@@ -1050,6 +1050,38 @@
- opens the Kafka triggers page
- does not write, deploy, or delete anything
- id: global-openpage7-compare-review
prompt: |-
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
toolExpect:
requiredToolsUsed:
- write_script
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- compare
# The eval chat is untracked (no modified-items mask), so the model must scope
# the review by passing the item it changed explicitly — an omitted mask would
# preselect every pending change in the workspace.
- tool: open_page
field: items
stringIncludesAnyOf:
- f/evals/global/compare_review_demo
skipJudge: true
judgeChecklist:
- creates the script draft, then opens the Compare & Deploy review page instead of deploying itself
- preselects only the created script on the review page
- does not deploy or delete anything
- id: global-closepage1-close-runs-tab
prompt: |-
You just opened the runs page for me in the side panel. Close that tab, I'm done looking at it.
+34
View File
@@ -174,6 +174,40 @@ describe("validateToolExpectations", () => {
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts a stringIncludesAnyOf substring inside an array-valued field", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["open_page"],
toolCallDetails: [
{
name: "open_page",
arguments: {
page: "compare",
items: ["script:f/evals/global/compare_review_demo"],
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["open_page"],
toolCallArgs: [
{
tool: "open_page",
field: "items",
stringIncludesAnyOf: ["f/evals/global/compare_review_demo"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts stringIncludesAnyOf when only one of several calls matches", () => {
// Existential: a mutation mixed with verification SELECTs still passes.
const checks = validateToolExpectations({
+9 -3
View File
@@ -239,9 +239,15 @@ export function validateToolExpectations(input: {
// model mixes the requested statement (e.g. an UPDATE) with verification
// SELECTs that would otherwise fail an "all calls" check.
const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase());
const hasMatch = values.some(
(value) =>
typeof value === "string" && needles.some((needle) => value.toLowerCase().includes(needle))
// Array-valued fields (e.g. open_page.items) match on any element.
const haystacks = (value: unknown): string[] =>
typeof value === "string"
? [value]
: Array.isArray(value)
? value.filter((v): v is string => typeof v === "string")
: [];
const hasMatch = values.some((value) =>
haystacks(value).some((hay) => needles.some((needle) => hay.toLowerCase().includes(needle)))
);
checks.push(
check(
@@ -1,5 +1,6 @@
<script lang="ts">
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import { maskHasDraftRow } from './sessions/modifiedItemsMask'
import DiffDrawer from './DiffDrawer.svelte'
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import DraftBadge from './DraftBadge.svelte'
@@ -315,9 +316,17 @@
// Default intent is deploy-all; when reached from a session's Review
// (chatMask set), preselect only that chat's items instead.
const selectable = visibleItems.filter(isSelectable)
selectedItems = (chatMask ? selectable.filter((i) => chatMask.has(i.key)) : selectable).map(
(i) => i.key
)
selectedItems = (
chatMask
? selectable.filter((i) =>
maskHasDraftRow(chatMask, {
kind: i.draftKind,
path: i.path,
draft_path: i.draft_path
})
)
: selectable
).map((i) => i.key)
hasAutoSelected = true
}
})
@@ -1722,6 +1722,7 @@ export class AIChatManager {
}
: {}),
testActiveFlow: async (args?: Record<string, any>) => this.flowAiChatHelpers?.testFlow(args),
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
attachedFiles: this.attachedFiles,
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
setUserInstructions: (instructions: string) => {
@@ -7,6 +7,7 @@
Database,
DollarSign,
FolderOpen,
GitCompareArrows,
KeyRound,
Mail,
Package,
@@ -69,7 +70,8 @@
folders: { title: 'Folders', icon: FolderOpen },
groups: { title: 'Groups', icon: Users },
triggers: { title: 'Triggers', icon: Zap },
workspace_settings: { title: 'Workspace settings', icon: Settings }
workspace_settings: { title: 'Workspace settings', icon: Settings },
compare: { title: 'Compare & Deploy', icon: GitCompareArrows }
}
function getActionCard(action: ToolDisplayAction): ActionCard {
@@ -253,6 +253,7 @@ vi.mock('$lib/infer', async () => ({
}))
import {
buildOpenPageUrl,
globalTools,
globalToolsFor,
prepareGlobalSystemMessage,
@@ -4171,3 +4172,36 @@ describe('prepareGlobalUserMessage', () => {
expect(message.content).toBe('## INSTRUCTIONS:\nCreate a draft')
})
})
describe('buildOpenPageUrl compare selection', () => {
const itemsOf = (url: string) => new URL(url, 'http://x').searchParams.get('items')
it('explicit items win over the chat mask', () => {
const url = buildOpenPageUrl(
'compare',
{ page: 'compare', items: ['script:f/a/b'] },
{ workspaceId: 'ws', chatItems: ['flow:f/c/d'] }
)
expect(itemsOf(url)).toBe('script:f/a/b')
})
it('omitted items fall back to the chat-modified mask', () => {
const url = buildOpenPageUrl(
'compare',
{ page: 'compare' },
{ workspaceId: 'ws', chatItems: ['flow:f/c/d', 'script:f/a/b'] }
)
expect(itemsOf(url)).toBe('flow:f/c/d,script:f/a/b')
})
it('an empty or absent mask yields no items param (page select-all default)', () => {
expect(
itemsOf(
buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws', chatItems: [] })
)
).toBeNull()
expect(
itemsOf(buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }))
).toBeNull()
})
})
@@ -134,8 +134,13 @@ import {
buildFoldersUrl,
buildGroupsUrl,
buildTriggersUrl,
buildCompareUrl,
WORKSPACE_SETTINGS_TABS
} from './pageNavigation'
import {
COMPARE_ITEMS_PARAM,
parseItemsMaskParam
} from '$lib/components/sessions/modifiedItemsMask'
import {
pageHref,
TRIGGER_PAGES,
@@ -994,6 +999,11 @@ ${pipelineBullet}
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run.
- Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself.
- When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" — it opens the Compare & Deploy review page.${
previewTools
? ' By default it preselects the items this chat modified; pass items ("<kind>:<path>" entries) to control the selection'
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
@@ -1903,7 +1913,8 @@ const OPEN_PAGE_NAMES = [
'folders',
'groups',
'triggers',
'workspace_settings'
'workspace_settings',
'compare'
] as const
type OpenPageName = (typeof OPEN_PAGE_NAMES)[number]
@@ -1917,7 +1928,8 @@ const OPEN_PAGE_LABELS: Record<OpenPageName, string> = {
folders: 'Folders',
groups: 'Groups',
triggers: 'Triggers',
workspace_settings: 'Workspace settings'
workspace_settings: 'Workspace settings',
compare: 'Compare & Deploy'
}
// Trigger kinds available given the workspace's license — the EE-gated kinds
@@ -1954,12 +1966,24 @@ function allowedOpenPages(workspaceId: string | undefined = get(workspaceStore))
'audit_logs',
'folders',
'groups',
'triggers'
'triggers',
'compare'
])
if (isAdmin) allowed.add('workspace_settings')
return OPEN_PAGE_NAMES.filter((p) => allowed.has(p))
}
// The advertised `items` description must match this chat's surface: only chats that
// track their modified items (AI sessions) can honor "omitted = this chat's edits" —
// on an untracked chat (the global side panel) an omitted mask falls through to the
// page's select-all default, so the model is told to pass the items explicitly there.
const COMPARE_ITEMS_DESCRIPTIONS = {
tracked:
"Compare: preselect exactly these changed items, each as '<kind>:<path>' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to preselect the items modified in this chat (everything when this chat modified nothing).",
untracked:
"Compare: preselect exactly these changed items, each as '<kind>:<path>' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). If omitted, the page preselects EVERY pending change in the workspace, not just this chat's — when you changed specific items, pass them so the review is scoped to them."
} as const
// One flat object (not a discriminated union): `page` selects the target and the
// per-page fields are optional. Top-level `type: object` is what Anthropic's
// input_schema requires; a top-level oneOf would be rejected. Each per-page URL builder
@@ -1967,20 +1991,7 @@ function allowedOpenPages(workspaceId: string | undefined = get(workspaceStore))
// apply to the chosen page is harmless. This full schema is used to PARSE tool args; the
// advertised schema (what the model sees) is narrowed per-user in `setSchema`.
const openPageFullSchema = z.object({
page: z
.enum([
'runs',
'schedules',
'variables',
'resources',
'assets',
'audit_logs',
'folders',
'groups',
'triggers',
'workspace_settings'
])
.describe('Which page to open'),
page: z.enum(OPEN_PAGE_NAMES).describe('Which page to open'),
path: z
.string()
.optional()
@@ -2031,6 +2042,13 @@ const openPageFullSchema = z.object({
.enum([...WORKSPACE_SETTINGS_TABS] as [string, ...string[]])
.optional()
.describe('Workspace settings: which settings tab to open'),
mode: z
.enum(['draft', 'fork'])
.optional()
.describe(
"Compare: which comparison to show — 'draft' (deployed items vs their pending drafts) or 'fork' (this forked workspace vs its parent). Omit to auto-pick: the view containing the preselected items (draft whenever any of them is a pending draft); with nothing preselected, fork on a forked workspace and draft otherwise."
),
items: z.array(z.string()).min(1).optional().describe(COMPARE_ITEMS_DESCRIPTIONS.tracked),
new_tab: z
.boolean()
.optional()
@@ -2057,7 +2075,9 @@ const OPEN_PAGE_FIELD_PAGES: Record<string, OpenPageName[]> = {
username: ['audit_logs'],
operation: ['audit_logs'],
resource: ['audit_logs'],
tab: ['workspace_settings']
tab: ['workspace_settings'],
mode: ['compare'],
items: ['compare']
}
// The model-facing schema for the given allowed pages: the `page` enum plus only the
@@ -2065,7 +2085,8 @@ const OPEN_PAGE_FIELD_PAGES: Record<string, OpenPageName[]> = {
// `trigger_kind` enum is narrowed to the license-available kinds.
function buildOpenPageDefSchema(
pages: readonly OpenPageName[],
triggerKinds: readonly PageTriggerKind[]
triggerKinds: readonly PageTriggerKind[],
chatEditsTracked: boolean
): z.ZodTypeAny {
const full = openPageFullSchema.shape as Record<string, z.ZodTypeAny>
// z.enum() rejects an empty list, and a user with no reachable pages (e.g. an operator
@@ -2084,16 +2105,27 @@ function buildOpenPageDefSchema(
.enum([...triggerKinds] as [string, ...string[]])
.optional()
.describe('Triggers: which trigger kind page to open')
: full[field]
: field === 'items'
? z
.array(z.string())
.min(1)
.optional()
.describe(COMPARE_ITEMS_DESCRIPTIONS[chatEditsTracked ? 'tracked' : 'untracked'])
: full[field]
}
shape.new_tab = full.new_tab
return z.object(shape)
}
const OPEN_PAGE_DESCRIPTION =
'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), or Workspace settings (on a specific tab). Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"). This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.'
'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy review page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and use page "compare" when the user wants to review and deploy pending changes (the items field controls which changes are preselected). This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.'
function buildOpenPageUrl(page: OpenPageName, a: OpenPageArgs): string {
// Non-arg inputs the URL builder needs: the chat's operating workspace (the compare
// page cannot fall back to its own store default inside a session preview) and the
// live modified-items mask backing the compare page's default preselection.
type OpenPageUrlCtx = { workspaceId: string; chatItems?: readonly string[] }
export function buildOpenPageUrl(page: OpenPageName, a: OpenPageArgs, ctx: OpenPageUrlCtx): string {
switch (page) {
case 'runs':
return buildRunsUrl({
@@ -2132,6 +2164,15 @@ function buildOpenPageUrl(page: OpenPageName, a: OpenPageArgs): string {
})
case 'workspace_settings':
return buildWorkspaceSettingsUrl({ tab: a.tab })
case 'compare':
// Explicit `items` wins; otherwise preselect this chat's modified items. An
// empty mask (chat modified nothing) passes no items so the page keeps its
// select-all default instead of preselecting nothing.
return buildCompareUrl({
workspace_id: ctx.workspaceId,
mode: a.mode,
items: a.items ?? (ctx.chatItems?.length ? ctx.chatItems : undefined)
})
}
}
@@ -2139,6 +2180,19 @@ function buildOpenPageUrl(page: OpenPageName, a: OpenPageArgs): string {
// hash target), or "all <page>" when unfiltered.
function summarizeOpenPage(url: string, page: OpenPageName): string {
const u = new URL(url, 'http://x')
if (page === 'compare') {
// The raw params (workspace_id + a possibly long items list) are noise here —
// summarize the selection instead.
const parts: string[] = []
const mode = u.searchParams.get('mode')
if (mode) parts.push(`mode=${mode}`)
const items = u.searchParams.get(COMPARE_ITEMS_PARAM)
if (items) {
const n = parseItemsMaskParam(items).size
parts.push(`${n} item${n === 1 ? '' : 's'} preselected`)
}
return parts.length ? parts.join(', ') : 'all pending changes'
}
const parts: string[] = []
u.searchParams.forEach((v, k) => parts.push(`${k}=${v}`))
if (u.hash) parts.push(u.hash.slice(1))
@@ -2146,8 +2200,10 @@ function summarizeOpenPage(url: string, page: OpenPageName): string {
}
export const openPageTool: Tool<{}> = {
// The initial def assumes an untracked chat; setSchema below rebuilds it with the
// caller's real surface before each iteration.
def: createToolDef(
buildOpenPageDefSchema(allowedOpenPages(), allowedTriggerKinds()),
buildOpenPageDefSchema(allowedOpenPages(), allowedTriggerKinds(), false),
'open_page',
OPEN_PAGE_DESCRIPTION
),
@@ -2162,7 +2218,8 @@ export const openPageTool: Tool<{}> = {
this.def = createToolDef(
buildOpenPageDefSchema(
allowedOpenPages(operatingWorkspaceFromHelpers(helpers)),
allowedTriggerKinds()
allowedTriggerKinds(),
(helpers as GlobalToolHelpers | undefined)?.getModifiedItems?.() !== undefined
),
'open_page',
OPEN_PAGE_DESCRIPTION
@@ -2185,7 +2242,14 @@ export const openPageTool: Tool<{}> = {
if (page === 'triggers' && triggerKind && !allowedTriggerKinds().includes(triggerKind)) {
return `${TRIGGER_PAGES[triggerKind].label} aren't available on this instance.`
}
const url = buildOpenPageUrl(page, parsed)
const urlWorkspace = workspaceId ?? get(workspaceStore)
if (!urlWorkspace) {
return 'Error: no workspace is selected, so no page can be opened.'
}
const url = buildOpenPageUrl(page, parsed, {
workspaceId: urlWorkspace,
chatItems: (ctx.helpers as GlobalToolHelpers | undefined)?.getModifiedItems?.()
})
const pageLabel = OPEN_PAGE_LABELS[page]
const summary = summarizeOpenPage(url, page)
@@ -3241,6 +3305,10 @@ export type GlobalToolHelpers = SessionToolHelpers & {
// Wired only for session chats (see AIChatManager): the artifact tools are session-gated.
artifacts?: SessionArtifactsStore
getChatId?: () => string | undefined
// Live snapshot of the items this chat modified (`kind:path` mask keys, see
// modifiedItemsMask.ts); undefined when the chat doesn't track them (the global
// side-panel chat). Backs open_page's compare-page default preselection.
getModifiedItems?: () => string[] | undefined
openArtifact?: (artifactId: string, name: string) => void
// Explicit "this chat is an AI session" marker for session-scoped gating
// (the pipeline gate). Do NOT infer it from `sessionId`: the eval harness
@@ -5,8 +5,10 @@ import {
buildResourcesUrl,
buildVariablesUrl,
buildTriggersUrl,
buildFoldersUrl
buildFoldersUrl,
buildCompareUrl
} from './pageNavigation'
import { parseItemsMaskParam } from '$lib/components/sessions/modifiedItemsMask'
function parse(appPath: string): URL {
return new URL(appPath, 'http://x')
@@ -63,4 +65,19 @@ describe('pageNavigation builders', () => {
expect(u.pathname).toBe('/folders')
expect(u.search).toBe('')
})
it('compare carries workspace, mode, and an items mask that round-trips through the page parser', () => {
const items = ['script:f/foo/bar', 'trigger_schedule:u/alice/daily']
const u = parse(buildCompareUrl({ workspace_id: 'wm-fork-x', mode: 'fork', items }))
expect(u.pathname).toBe('/forks/compare')
expect(u.searchParams.get('workspace_id')).toBe('wm-fork-x')
expect(u.searchParams.get('mode')).toBe('fork')
expect(parseItemsMaskParam(u.searchParams.get('items')!)).toEqual(new Set(items))
})
it('compare omits mode and items when not provided', () => {
const u = parse(buildCompareUrl({ workspace_id: 'ws' }))
expect(u.searchParams.get('mode')).toBeNull()
expect(u.searchParams.get('items')).toBeNull()
})
})
@@ -1,7 +1,15 @@
import { buildFilterUrl } from '$lib/navigation'
import { buildRunsFilterSearchbarSchema } from '$lib/components/runs/runsFilter'
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
import { TRIGGER_PAGES, type TriggerKind } from '$lib/components/sessions/previewRouter'
import {
COMPARE_PAGE,
TRIGGER_PAGES,
type TriggerKind
} from '$lib/components/sessions/previewRouter'
import {
COMPARE_ITEMS_PARAM,
serializeItemsMaskParam
} from '$lib/components/sessions/modifiedItemsMask'
// In-app paths for the deep-linkable preview pages the AI chat can open.
export const RUNS_PATH = '/runs'
@@ -118,6 +126,36 @@ export function buildGroupsUrl(): string {
return GROUPS_PATH
}
/**
* Deep-link to the Compare & Deploy page (`/forks/compare`). `workspace_id` is required:
* inside a session preview the page loads with the *navigation* workspace as its store
* default, which is not necessarily the session's (possibly forked) workspace. `items`
* preselects exactly those `kind:path` entries (see modifiedItemsMask.ts); omitted, the
* page falls back to its select-all default. `mode` forces the draft or fork comparison;
* omitted, the page auto-picks: on a fork it lands on the view containing the masked
* items (draft when any of them is a pending draft, else the fork comparison); a
* non-fork always gets the draft view.
*/
export function buildCompareUrl({
workspace_id,
mode,
items
}: {
workspace_id: string
mode?: 'draft' | 'fork'
items?: readonly string[]
}): string {
return buildFilterUrl(
COMPARE_PAGE.path,
{
workspace_id,
mode,
[COMPARE_ITEMS_PARAM]: items ? serializeItemsMaskParam(items) : undefined
},
{ validKeys: ['workspace_id', 'mode', COMPARE_ITEMS_PARAM] }
)
}
/**
* Deep-link to a trigger list page (by kind). When `open` is set, the trigger at that
* exact path is opened in the edit drawer via the `#<path>` hash the page handles.
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest'
import { maskKey, forkDiffKindToUserDraftKind, diffInMask } from './modifiedItemsMask'
import {
maskKey,
forkDiffKindToUserDraftKind,
diffInMask,
maskHasDraftRow
} from './modifiedItemsMask'
import type { WorkspaceItemDiff } from '$lib/gen'
// The fork-diff → user-draft kind bridge must stay the inverse of
@@ -57,6 +62,12 @@ describe('diffInMask', () => {
expect(diffInMask(diff('script', 'u/me/s'), mask)).toBe(true)
})
it('matches a legacy app diff under both its identity and bridged mask keys', () => {
expect(diffInMask(diff('app', 'u/me/legacy'), new Set(['app:u/me/legacy']))).toBe(true)
expect(diffInMask(diff('app', 'u/me/legacy'), new Set(['raw_app:u/me/legacy']))).toBe(true)
expect(diffInMask(diff('app', 'u/me/legacy'), new Set(['app:u/me/other']))).toBe(false)
})
it('does not match when path or kind differ, or kind has no equivalent', () => {
const mask = new Set(['trigger_http:f/foo/route'])
expect(diffInMask(diff('http_trigger', 'f/other/route'), mask)).toBe(false)
@@ -64,3 +75,25 @@ describe('diffInMask', () => {
expect(diffInMask(diff('folder', 'f/foo/route'), mask)).toBe(false)
})
})
describe('maskHasDraftRow', () => {
it('matches by storage path or, for a parked live draft, by its visible draft_path', () => {
const mask = new Set(['script:u/me/my_script'])
expect(maskHasDraftRow(mask, { kind: 'script', path: 'u/me/my_script' })).toBe(true)
expect(
maskHasDraftRow(mask, {
kind: 'script',
path: 'u/me/draft_123',
draft_path: 'u/me/my_script'
})
).toBe(true)
})
it('does not match a different path or kind', () => {
const mask = new Set(['script:u/me/my_script'])
expect(maskHasDraftRow(mask, { kind: 'script', path: 'u/me/other' })).toBe(false)
expect(
maskHasDraftRow(mask, { kind: 'flow', path: 'u/me/draft_123', draft_path: 'u/me/my_script' })
).toBe(false)
})
})
@@ -48,5 +48,42 @@ export function forkDiffKindToUserDraftKind(kind: ForkDiffKind): UserDraftItemKi
// True when a fork-comparison diff names an item present in the chat-modified mask.
export function diffInMask(diff: WorkspaceItemDiff, mask: Set<string>): boolean {
const kind = forkDiffKindToUserDraftKind(diff.kind)
return kind !== undefined && mask.has(maskKey(kind, diff.path))
if (kind !== undefined && mask.has(maskKey(kind, diff.path))) return true
// Legacy drag-and-drop apps tally fork diffs under `app`, and an explicit
// `?items=` mask names them `app:<path>` (the same kind the drafts list uses).
// The bridged lookup above reads them as `raw_app` (kept for chat masks, which
// only ever record raw apps), so accept the identity key too.
return diff.kind === 'app' && mask.has(maskKey('app', diff.path))
}
// `?items=` on the compare page: an explicit preselection mask passed in the URL
// (the chat's open_page tool builds it; the page parses it). Comma-separated
// `kind:path` keys — safe because Windmill paths cannot contain commas. An empty
// value parses to an empty set, i.e. "preselect nothing", distinct from the param
// being absent (no mask → the page's select-all default).
export const COMPARE_ITEMS_PARAM = 'items'
export function serializeItemsMaskParam(keys: readonly string[]): string {
return keys.join(',')
}
export function parseItemsMaskParam(value: string): Set<string> {
return new Set(
value
.split(',')
.map((k) => k.trim())
.filter(Boolean)
)
}
// Whether a mask names a draft row. Mask entries key items by storage path
// (`maskKey`), but a never-deployed live-editor draft parks at an opaque
// `draft_<uuid>` storage path while callers building an explicit `?items=` mask
// know it by its visible `draft_path` — so a row matches under either name.
export function maskHasDraftRow(
mask: Set<string>,
row: { kind: UserDraftItemKind; path: string; draft_path?: string }
): boolean {
if (mask.has(maskKey(row.kind, row.path))) return true
return !!row.draft_path && mask.has(maskKey(row.kind, row.draft_path))
}
@@ -2,12 +2,27 @@ import { describe, it, expect } from 'vitest'
import {
artifactUrl,
draftFriendlyLeaf,
matchReusablePage,
parseArtifactRoute,
parsePreviewItemRoute,
previewLocationLabel,
resolvePreviewTab
} from './previewRouter'
describe('matchReusablePage', () => {
it('matches curated pages and the compare page, ignoring query params', () => {
expect(matchReusablePage('/runs?path=f/a/b')?.path).toBe('/runs')
expect(matchReusablePage('/forks/compare?workspace_id=ws&items=script:f/a/b')?.path).toBe(
'/forks/compare'
)
expect(previewLocationLabel('/forks/compare?workspace_id=ws')).toBe('Compare & Deploy')
})
it('does not match trigger pages (they dedupe on exact URL)', () => {
expect(matchReusablePage('/kafka_triggers')).toBeUndefined()
})
})
describe('parsePreviewItemRoute', () => {
it('maps edit/get routes to item kinds', () => {
expect(parsePreviewItemRoute('/scripts/edit/f/foo/bar')).toEqual({
@@ -7,6 +7,7 @@ import {
Calendar,
Database,
FolderOpen,
GitCompareArrows,
Users,
Settings,
ScrollText
@@ -77,6 +78,16 @@ export function triggerLabelForPath(path: string): string | undefined {
return Object.values(TRIGGER_PAGES).find((t) => t.path === clean)?.label
}
// The Compare & Deploy review page. Kept out of PREVIEW_PAGES (it's not a picker
// destination — it's reached through the chat's open_page tool or a session's
// Review button) but known here so preview tabs label it and reuse it on
// param changes like the curated pages.
export const COMPARE_PAGE: PreviewPage = {
label: 'Compare & Deploy',
path: '/forks/compare',
icon: GitCompareArrows
}
export const pageKey = (path: string) => `page:${path}`
export const pageHref = (path: string) => `${base}${path}`
@@ -95,13 +106,22 @@ export function matchPreviewPage(path: string): PreviewPage | undefined {
return PREVIEW_PAGES.find((p) => p.path === clean)
}
/** Match a preview href to a page whose tab should be re-pointed in place when
* only its query params change (the open_page filter-change behavior): the
* curated pages plus the compare page. Trigger pages are deliberately not
* matched — their tabs dedupe on the exact URL instead. */
export function matchReusablePage(href: string): PreviewPage | undefined {
if (stripBase(href) === COMPARE_PAGE.path) return COMPARE_PAGE
return matchPreviewPage(href)
}
/** Human label for a preview tab's location — the workspace page name, trigger
* page, run detail, or item path. Shared by the sessions tab strip and the
* close_page matcher so both name a tab the same way. */
export function previewLocationLabel(url: string): string {
const artifact = parseArtifactRoute(url)
if (artifact) return artifact.name || 'Artifact'
const page = matchPreviewPage(url)
const page = matchReusablePage(url)
if (page) return page.label
const trigger = triggerLabelForPath(url)
if (trigger) return trigger
@@ -50,7 +50,7 @@ import {
selectPreviewTabsToClose
} from './sessionPreviewTabs.svelte'
import {
matchPreviewPage,
matchReusablePage,
parsePreviewItemRoute,
previewLocationLabel,
resolvePreviewTab
@@ -1000,10 +1000,10 @@ setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label, newTab })
// filter change updates it in place instead of spawning a duplicate — unless the
// user asked for a separate tab. open() dedupes on the exact URL, so differing
// filters would otherwise always open a new tab.
const targetPage = matchPreviewPage(href)
const targetPage = matchReusablePage(href)
if (!newTab && targetPage) {
const existing = owner.tabs.find(
(t) => matchPreviewPage(t.loc || t.url)?.path === targetPage.path
(t) => matchReusablePage(t.loc || t.url)?.path === targetPage.path
)
if (existing) {
owner.select(existing.id)
@@ -20,6 +20,11 @@
import { switchWorkspace } from '$lib/storeUtils'
import { goto } from '$lib/navigation'
import { readChatModifiedItems } from '$lib/components/copilot/chat/HistoryManager.svelte'
import {
COMPARE_ITEMS_PARAM,
maskHasDraftRow,
parseItemsMaskParam
} from '$lib/components/sessions/modifiedItemsMask'
type CompareMode = 'fork' | 'draft'
@@ -49,6 +54,15 @@
// selection here; the page only swaps which comparison component is shown.
let forkDirection = $state<'deploy_to' | 'update'>('deploy_to')
// Explicit preselection via `?items=<kind:path,...>` (built by the chat's
// open_page tool). Parsed synchronously from the live URL so it can never race
// the children's select-all default. Present-but-empty means "preselect
// nothing", distinct from absent (undefined → no mask).
const urlItemsMask = $derived.by(() => {
const v = page.url.searchParams.get(COMPARE_ITEMS_PARAM)
return v === null ? undefined : parseItemsMaskParam(v)
})
// When reached via a session's Review button (`from_session=<chatId>`), preselect
// only the items that chat modified. The mask is the chat's stored
// `${UserDraftItemKind}:${storagePath}` set; undefined for a legacy chat (no
@@ -56,30 +70,33 @@
// Derived from the live URL: an in-app navigation to this route with a
// different from_session must reload the mask, not keep the first one.
const fromChatId = $derived(page.url.searchParams.get('from_session'))
let chatMask = $state<Set<string> | undefined>(undefined)
let sessionMask = $state<Set<string> | undefined>(undefined)
// The mask loads asynchronously, while the resolved value can legitimately be
// undefined (legacy chat). The children must not run their select-all default
// until the mask is known, else they'd race it and select everything. Ready
// immediately when there's no session to read from.
let chatMaskReady = $state(!page.url.searchParams.get('from_session'))
let sessionMaskReady = $state(!page.url.searchParams.get('from_session'))
$effect(() => {
const id = fromChatId
chatMask = undefined
chatMaskReady = !id
sessionMask = undefined
sessionMaskReady = !id
if (!id) return
untrack(() => {
void readChatModifiedItems(id)
.then((arr) => {
// A slower read for a superseded chat id must not win.
if (id !== untrack(() => fromChatId)) return
chatMask = arr ? new Set(arr) : undefined
sessionMask = arr ? new Set(arr) : undefined
})
.finally(() => {
if (id === untrack(() => fromChatId)) chatMaskReady = true
if (id === untrack(() => fromChatId)) sessionMaskReady = true
})
})
})
const chatMask = $derived(urlItemsMask ?? sessionMask)
const chatMaskReady = $derived(urlItemsMask !== undefined || sessionMaskReady)
function selectMode(v: 'deploy_to' | 'update' | 'draft') {
if (v === 'draft') {
mode = 'draft'
@@ -133,8 +150,40 @@
$effect(() => {
if (modeResolved || !currentWorkspaceData) return
if (!isFork) {
untrack(() => {
mode = 'draft'
modeResolved = true
})
return
}
// An explicit ?mode=fork is only deferred (not latched at init) so the
// non-fork fallback above can veto it — on a real fork, honor it as is.
if (urlMode === 'fork') {
untrack(() => {
mode = 'fork'
modeResolved = true
})
return
}
// A fork reached with a preselection mask but no ?mode= must land on the
// view where the masked items actually are: a chat's pending drafts have no
// fork-diff row, so fork mode would open with none of them selected. Defer
// until the mask and the draft list are known, then prefer the draft view
// when any masked item is a pending draft; else keep the fork comparison.
if (!chatMaskReady) return
const mask = chatMask
if (mask?.size) {
if (drafts.loading) return
const masksDraft = drafts.items.some((d) => maskHasDraftRow(mask, d))
untrack(() => {
mode = masksDraft ? 'draft' : 'fork'
modeResolved = true
})
return
}
untrack(() => {
mode = isFork ? 'fork' : 'draft'
mode = 'fork'
modeResolved = true
})
})