@@ -32,6 +32,7 @@ import {
3232 MESSAGE_TOO_LARGE_CODE ,
3333 MESSAGE_TOO_LARGE_ERROR ,
3434} from "~/components/dashboard-agent/message-limits" ;
35+ import { MAX_URIS_PER_RESOLVE_REQUEST } from "~/components/dashboard-agent/resolve-uris" ;
3536import { $replica } from "~/db.server" ;
3637import { env } from "~/env.server" ;
3738import { findProjectBySlug } from "~/models/project.server" ;
@@ -82,6 +83,7 @@ const ActionBody = z.object({
8283 "delete" ,
8384 "read" ,
8485 "resolve" ,
86+ "resolve-many" ,
8587 "watch-cancel" ,
8688 "watch-create" ,
8789 ] ) ,
@@ -94,6 +96,8 @@ const ActionBody = z.object({
9496 pinned : z . enum ( [ "true" , "false" ] ) . optional ( ) ,
9597 // A `trigger://` URI, for `resolve`.
9698 uri : z . string ( ) . optional ( ) ,
99+ // A JSON array of `trigger://` URIs, for `resolve-many`.
100+ uris : z . string ( ) . optional ( ) ,
97101 // The watch to cancel, for `watch-cancel`.
98102 watchId : z . string ( ) . min ( 1 ) . optional ( ) ,
99103 // The configured card, for `watch-create`: a JSON `WatchDraft`.
@@ -207,6 +211,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
207211 } ) ;
208212} ;
209213
214+ /** Only a source URI needs the connected repository, so a batch without one skips the read. */
215+ async function findRepositoryForSourceUris ( projectId : string , uris : string [ ] ) {
216+ if ( ! uris . some ( ( uri ) => uri . includes ( "/source/" ) ) ) return null ;
217+
218+ const connected = await $replica . connectedGithubRepository . findFirst ( {
219+ where : {
220+ projectId,
221+ repository : { installation : { deletedAt : null , suspendedAt : null } } ,
222+ } ,
223+ select : { repository : { select : { fullName : true } } } ,
224+ } ) ;
225+ return connected ?. repository ?? null ;
226+ }
227+
210228function messageTooLarge ( ) {
211229 return json ( { error : MESSAGE_TOO_LARGE_ERROR , code : MESSAGE_TOO_LARGE_CODE } , { status : 413 } ) ;
212230}
@@ -335,21 +353,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
335353 const environment = await findEnvironmentBySlug ( project . id , envParam , userId ) ;
336354 if ( ! environment ) return json ( { error : "Environment not found" } , { status : 404 } ) ;
337355
338- // Only a source URI needs the connected repository.
339- const repository = uri . includes ( "/source/" )
340- ? await $replica . connectedGithubRepository . findFirst ( {
341- where : {
342- projectId : project . id ,
343- repository : { installation : { deletedAt : null , suspendedAt : null } } ,
344- } ,
345- select : { repository : { select : { fullName : true } } } ,
346- } )
347- : null ;
356+ const repository = await findRepositoryForSourceUris ( project . id , [ uri ] ) ;
348357
349- const resolved = resolveTriggerUri (
350- { ...environment , repository : repository ?. repository ?? null } ,
351- uri
352- ) ;
358+ const resolved = resolveTriggerUri ( { ...environment , repository } , uri ) ;
353359 if ( ! resolved ) return json ( { error : "Nothing to open for that link" } , { status : 404 } ) ;
354360
355361 return json ( {
@@ -359,6 +365,43 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
359365 } ) ;
360366 }
361367
368+ // The card's citations in one request: one environment lookup and one repo lookup for the
369+ // whole batch, same environment scope as `resolve`.
370+ if ( parsed . data . intent === "resolve-many" ) {
371+ let uris : string [ ] ;
372+ try {
373+ const list = JSON . parse ( parsed . data . uris ?? "" ) as unknown ;
374+ if ( ! Array . isArray ( list ) || list . some ( ( uri ) => typeof uri !== "string" ) ) {
375+ return json ( { error : "uris is required" } , { status : 400 } ) ;
376+ }
377+ uris = [ ...new Set ( list as string [ ] ) ] ;
378+ } catch {
379+ return json ( { error : "uris is required" } , { status : 400 } ) ;
380+ }
381+
382+ if ( uris . length === 0 ) return json ( { error : "uris is required" } , { status : 400 } ) ;
383+ if ( uris . length > MAX_URIS_PER_RESOLVE_REQUEST ) {
384+ return json ( { error : "Too many links in one request" } , { status : 400 } ) ;
385+ }
386+
387+ const environment = await findEnvironmentBySlug ( project . id , envParam , userId ) ;
388+ if ( ! environment ) return json ( { error : "Environment not found" } , { status : 404 } ) ;
389+
390+ const repository = await findRepositoryForSourceUris ( project . id , uris ) ;
391+ const scope = { ...environment , repository } ;
392+
393+ // A null entry is the definitive "nothing to open": the client caches it.
394+ const resolved : Record < string , { path : string ; label : string ; external : boolean } | null > = { } ;
395+ for ( const uri of uris ) {
396+ const hit = resolveTriggerUri ( scope , uri ) ;
397+ resolved [ uri ] = hit
398+ ? { path : hit . url , label : hit . label , external : hit . external ?? false }
399+ : null ;
400+ }
401+
402+ return json ( { resolved } ) ;
403+ }
404+
362405 // The configuration card's submit path. The environment comes from the URL and goes
363406 // through the same re-authorization a background tick passes, never from the body.
364407 if ( parsed . data . intent === "watch-create" ) {
0 commit comments