From a3a4c5d3f5d25946e69381ec8f5d8491fb010ceb Mon Sep 17 00:00:00 2001 From: Alexander Richey Date: Fri, 4 Sep 2026 20:54:30 +0000 Subject: [PATCH] fix(tui): carry the region override through a resource's whole flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A detail page opened through project status or a harness's linked rows fetches in the region named by ?region=, but its actions are routes of their own: enter on `detail`, `endpoints`, `events` and the rest dropped the query, so the next screen fell back to the ambient region and failed to find a resource deployed elsewhere. useRegionNavigate wraps useNavigate for the runtime, memory, gateway, harness and identity screens: string routes carry the current screen's ?region= forward (withRegion, which leaves a route that already names a region alone), history moves pass through untouched. Every TUI screen and picker now resolves its Core options through useCoreOpts rather than coreOptsFromCtx, so the destination honours the override wherever the flow goes — a hub's actions, the lists they open, the rows those lists forward to, and the JSON views. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016KnHdxcPYTnQiDY7Y1PY6s --- README.md | 3 +- src/components/AbTestPicker.tsx | 4 +- src/components/BatchEvaluationPicker.tsx | 4 +- src/components/BatchInsightsPicker.tsx | 4 +- src/components/ConfigBundlePicker.tsx | 4 +- src/components/ConfigBundleVersionPicker.tsx | 4 +- src/components/DatasetPicker.tsx | 4 +- src/components/EndpointWizard.tsx | 7 +- src/components/EvaluatorPicker.tsx | 4 +- src/components/GatewayConnectorPicker.tsx | 4 +- src/components/GatewayPicker.tsx | 4 +- src/components/GatewayRulePicker.tsx | 4 +- src/components/GatewayTargetPicker.tsx | 4 +- src/components/HarnessEndpointPicker.tsx | 4 +- src/components/HarnessPicker.tsx | 4 +- src/components/HarnessVersionPicker.tsx | 4 +- src/components/HarnessWizard.tsx | 4 +- src/components/MemoryPicker.tsx | 4 +- src/components/OnlineEvalPicker.tsx | 4 +- src/components/OnlineInsightPicker.tsx | 4 +- src/components/RuntimeEndpointPicker.tsx | 4 +- src/components/RuntimePicker.tsx | 4 +- src/components/RuntimeVersionPicker.tsx | 4 +- src/handlers/eval/ab-test/get/screen.tsx | 4 +- .../eval/batch-evaluation/get/screen.tsx | 4 +- .../eval/batch-insights/get/screen.tsx | 4 +- .../eval/config-bundle/get/screen.tsx | 4 +- src/handlers/eval/dataset/get/screen.tsx | 4 +- src/handlers/eval/evaluator/get/screen.tsx | 4 +- src/handlers/eval/online-eval/get/screen.tsx | 4 +- .../eval/online-insight/get/screen.tsx | 4 +- src/handlers/gateway/connector/get/screen.tsx | 4 +- .../gateway/connector/list/screen.tsx | 5 +- src/handlers/gateway/gateway.screen.test.tsx | 28 +++++++- src/handlers/gateway/get/screen.tsx | 6 +- src/handlers/gateway/invoke/screen.tsx | 10 +-- src/handlers/gateway/list/screen.tsx | 4 +- src/handlers/gateway/policy/screen.tsx | 10 +-- src/handlers/gateway/rule/get/screen.tsx | 4 +- src/handlers/gateway/rule/list/screen.tsx | 5 +- src/handlers/gateway/target/list/screen.tsx | 5 +- src/handlers/harness/delete/screen.tsx | 10 +-- .../harness/endpoint/create/screen.tsx | 5 +- .../harness/endpoint/delete/screen.tsx | 8 +-- src/handlers/harness/endpoint/get/screen.tsx | 4 +- src/handlers/harness/endpoint/list/screen.tsx | 5 +- .../harness/endpoint/update/screen.tsx | 8 +-- src/handlers/harness/exec/screen.tsx | 5 +- src/handlers/harness/get/get.screen.test.tsx | 44 +++++++++++++ src/handlers/harness/get/screen.tsx | 6 +- src/handlers/harness/invoke/screen.tsx | 10 +-- src/handlers/harness/list/screen.tsx | 4 +- src/handlers/harness/update/screen.tsx | 10 +-- src/handlers/harness/version/get/screen.tsx | 4 +- src/handlers/harness/version/list/screen.tsx | 5 +- .../get/screen.tsx | 6 +- .../list/screen.tsx | 7 +- .../oauth2-credential-provider/get/screen.tsx | 6 +- .../list/screen.tsx | 7 +- src/handlers/memory/actor/list/screen.tsx | 5 +- src/handlers/memory/event/get/screen.tsx | 4 +- src/handlers/memory/event/list/screen.tsx | 10 +-- src/handlers/memory/get/screen.tsx | 6 +- src/handlers/memory/list/screen.tsx | 4 +- src/handlers/memory/listPickers.tsx | 6 +- src/handlers/memory/memory.screen.test.tsx | 22 +++++++ src/handlers/memory/record/get/screen.tsx | 4 +- src/handlers/memory/record/list/screen.tsx | 12 ++-- src/handlers/memory/session/list/screen.tsx | 5 +- .../project/status/status.screen.test.tsx | 66 +++++++++++++++++++ src/handlers/runtime/endpoint/get/screen.tsx | 8 +-- src/handlers/runtime/endpoint/list/screen.tsx | 5 +- src/handlers/runtime/get/screen.tsx | 6 +- src/handlers/runtime/invoke/screen.tsx | 10 +-- src/handlers/runtime/list/screen.tsx | 4 +- src/handlers/runtime/runtime.screen.test.tsx | 33 ++++++++++ src/handlers/runtime/shell/screen.tsx | 7 +- src/handlers/runtime/version/get/screen.tsx | 4 +- src/handlers/runtime/version/list/screen.tsx | 5 +- src/handlers/utils.test.tsx | 30 ++++++++- src/handlers/utils.tsx | 35 +++++++++- 81 files changed, 457 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 79822cc32..b48d2b575 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ headless). A bare `project status` opens a Linked Resources view that groups the project's resources by agent and forwards to each deployed resource's detail page. The harness hub (`harness get`) ends with the same kind of Linked Resources tree for the Runtime, Memory, Gateway, Browser, Code Interpreter and -credential providers wired to that harness, each opening in its own region. +credential providers wired to that harness, each opening in its own region; the +region then follows that resource through its actions, lists and detail views. ``` agentcore # interactive TUI diff --git a/src/components/AbTestPicker.tsx b/src/components/AbTestPicker.tsx index c27a53209..56722a8dd 100644 --- a/src/components/AbTestPicker.tsx +++ b/src/components/AbTestPicker.tsx @@ -1,7 +1,7 @@ import type { ABTestSummary } from "@aws-sdk/client-bedrock-agentcore"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -47,7 +47,7 @@ export function AbTestPicker({ onSelect, onEscape, }: AbTestPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/BatchEvaluationPicker.tsx b/src/components/BatchEvaluationPicker.tsx index 0b202af98..e0619a61b 100644 --- a/src/components/BatchEvaluationPicker.tsx +++ b/src/components/BatchEvaluationPicker.tsx @@ -1,7 +1,7 @@ import type { BatchEvaluationSummary } from "@aws-sdk/client-bedrock-agentcore"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -58,7 +58,7 @@ export function BatchEvaluationPicker({ onSelect, onEscape, }: BatchEvaluationPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/BatchInsightsPicker.tsx b/src/components/BatchInsightsPicker.tsx index 649a146a0..eb587ce1b 100644 --- a/src/components/BatchInsightsPicker.tsx +++ b/src/components/BatchInsightsPicker.tsx @@ -1,7 +1,7 @@ import type { BatchEvaluationSummary } from "@aws-sdk/client-bedrock-agentcore"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -49,7 +49,7 @@ export function BatchInsightsPicker({ onSelect, onEscape, }: BatchInsightsPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/ConfigBundlePicker.tsx b/src/components/ConfigBundlePicker.tsx index 19572d11b..f70c48ea2 100644 --- a/src/components/ConfigBundlePicker.tsx +++ b/src/components/ConfigBundlePicker.tsx @@ -1,7 +1,7 @@ import type { ConfigurationBundleSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -50,7 +50,7 @@ export function ConfigBundlePicker({ onSelect, onEscape, }: ConfigBundlePickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/ConfigBundleVersionPicker.tsx b/src/components/ConfigBundleVersionPicker.tsx index b1a7102cb..02e76c228 100644 --- a/src/components/ConfigBundleVersionPicker.tsx +++ b/src/components/ConfigBundleVersionPicker.tsx @@ -1,6 +1,6 @@ import type { ConfigurationBundleVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -49,7 +49,7 @@ export function ConfigBundleVersionPicker({ onSelect, onBack, }: ConfigBundleVersionPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return ( navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/EndpointWizard.tsx b/src/components/EndpointWizard.tsx index 74372e8f2..519883af5 100644 --- a/src/components/EndpointWizard.tsx +++ b/src/components/EndpointWizard.tsx @@ -1,13 +1,12 @@ import { useMemo, useState } from "react"; import { Box, Text, useInput } from "ink"; -import { useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import type { CreateHarnessEndpointRequest, UpdateHarnessEndpointRequest, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts, useRegionNavigate } from "../handlers/utils"; import { Layout } from "./Layout"; import { FormRadioGroup, type FormRadioOption } from "./FormRadioGroup"; import { FormTextInput } from "./FormTextInput"; @@ -60,8 +59,8 @@ export function EndpointWizard({ initial, onDone, }: EndpointWizardProps) { - const navigate = useNavigate(); - const opts = coreOptsFromCtx(ctx); + const navigate = useRegionNavigate(); + const opts = useCoreOpts(ctx); const steps: Step[] = useMemo(() => { const all: Step[] = [ diff --git a/src/components/EvaluatorPicker.tsx b/src/components/EvaluatorPicker.tsx index d0b1ebec7..33a6d9d18 100644 --- a/src/components/EvaluatorPicker.tsx +++ b/src/components/EvaluatorPicker.tsx @@ -1,7 +1,7 @@ import type { EvaluatorSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -62,7 +62,7 @@ export function EvaluatorPicker({ onSelect, onEscape, }: EvaluatorPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/GatewayConnectorPicker.tsx b/src/components/GatewayConnectorPicker.tsx index 17f115ce7..794373c9c 100644 --- a/src/components/GatewayConnectorPicker.tsx +++ b/src/components/GatewayConnectorPicker.tsx @@ -1,7 +1,7 @@ import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -48,7 +48,7 @@ export function GatewayConnectorPicker({ description, onSelect, }: GatewayConnectorPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); return ( diff --git a/src/components/GatewayPicker.tsx b/src/components/GatewayPicker.tsx index ec688260c..9ff92cfae 100644 --- a/src/components/GatewayPicker.tsx +++ b/src/components/GatewayPicker.tsx @@ -1,7 +1,7 @@ import type { GatewaySummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -54,7 +54,7 @@ export function GatewayPicker({ onSelect, onEscape, }: GatewayPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); return ( diff --git a/src/components/GatewayRulePicker.tsx b/src/components/GatewayRulePicker.tsx index 17cfb6ddb..12924ca17 100644 --- a/src/components/GatewayRulePicker.tsx +++ b/src/components/GatewayRulePicker.tsx @@ -1,7 +1,7 @@ import type { GatewayRuleDetail } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -48,7 +48,7 @@ export function GatewayRulePicker({ description, onSelect, }: GatewayRulePickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); return ( diff --git a/src/components/GatewayTargetPicker.tsx b/src/components/GatewayTargetPicker.tsx index 19e5eb2ec..20780137e 100644 --- a/src/components/GatewayTargetPicker.tsx +++ b/src/components/GatewayTargetPicker.tsx @@ -1,7 +1,7 @@ import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -51,7 +51,7 @@ export function GatewayTargetPicker({ description, onSelect, }: GatewayTargetPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); return ( diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index fae3f139a..d392e0151 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router"; import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -68,7 +68,7 @@ export function HarnessEndpointPicker({ onSelect, onEscape, }: HarnessEndpointPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate(-1)); diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index 89cd8a4d5..22e49da0d 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router"; import type { HarnessSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -65,7 +65,7 @@ export function HarnessPicker({ description, onSelect, }: HarnessPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx index 8a35ac126..1d8b90d80 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router"; import type { HarnessVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -59,7 +59,7 @@ export function HarnessVersionPicker({ description, onSelect, }: HarnessVersionPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = () => navigate(-1); diff --git a/src/components/HarnessWizard.tsx b/src/components/HarnessWizard.tsx index 5be985378..44adb9f6b 100644 --- a/src/components/HarnessWizard.tsx +++ b/src/components/HarnessWizard.tsx @@ -8,7 +8,7 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CreateHarnessInput } from "../handlers/harness/types"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { Layout } from "./Layout"; import { ErrorPanel } from "./ErrorPanel"; import { FormTextInput } from "./FormTextInput"; @@ -268,7 +268,7 @@ export function HarnessWizard({ onDone, onExit, }: HarnessWizardProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const steps: Step[] = useMemo(() => { const all: Step[] = [ diff --git a/src/components/MemoryPicker.tsx b/src/components/MemoryPicker.tsx index 98005d6b9..fc234a6f6 100644 --- a/src/components/MemoryPicker.tsx +++ b/src/components/MemoryPicker.tsx @@ -1,7 +1,7 @@ import type { MemorySummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -38,7 +38,7 @@ export interface MemoryPickerProps extends ScreenProps { } export function MemoryPicker({ ctx, core, breadcrumb, description, onSelect }: MemoryPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); diff --git a/src/components/OnlineEvalPicker.tsx b/src/components/OnlineEvalPicker.tsx index 008225bcd..5fe4da1b6 100644 --- a/src/components/OnlineEvalPicker.tsx +++ b/src/components/OnlineEvalPicker.tsx @@ -1,7 +1,7 @@ import type { OnlineEvaluationConfigSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -63,7 +63,7 @@ export function OnlineEvalPicker({ onSelect, onEscape, }: OnlineEvalPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/OnlineInsightPicker.tsx b/src/components/OnlineInsightPicker.tsx index 2b5f57b99..3c851bd5b 100644 --- a/src/components/OnlineInsightPicker.tsx +++ b/src/components/OnlineInsightPicker.tsx @@ -1,7 +1,7 @@ import type { OnlineEvaluationConfigSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -63,7 +63,7 @@ export function OnlineInsightPicker({ onSelect, onEscape, }: OnlineInsightPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index fcc053124..37cf980ca 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -1,7 +1,7 @@ import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -54,7 +54,7 @@ export function RuntimeEndpointPicker({ onSelect, onEscape, }: RuntimeEndpointPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate(-1)); diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx index a057ab4c6..71f0b04b9 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -1,7 +1,7 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -52,7 +52,7 @@ export function RuntimePicker({ onSelect, onEscape, }: RuntimePickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); diff --git a/src/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx index 64954348a..12b3f35ba 100644 --- a/src/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -1,7 +1,7 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; +import { useCoreOpts } from "../handlers/utils"; import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; import type { DataTableColumn } from "./ui/data-table"; @@ -47,7 +47,7 @@ export function RuntimeVersionPicker({ description, onSelect, }: RuntimeVersionPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const navigate = useNavigate(); const goBack = () => navigate(-1); diff --git a/src/handlers/eval/ab-test/get/screen.tsx b/src/handlers/eval/ab-test/get/screen.tsx index 782013d3b..37deda036 100644 --- a/src/handlers/eval/ab-test/get/screen.tsx +++ b/src/handlers/eval/ab-test/get/screen.tsx @@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; function useAbTestDetail({ ctx, core }: ScreenProps, abTestId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["ab-test", opts.region, abTestId], queryFn: () => core.eval.getABTest(abTestId!, opts), diff --git a/src/handlers/eval/batch-evaluation/get/screen.tsx b/src/handlers/eval/batch-evaluation/get/screen.tsx index c8fe68e8e..7d94cdbd7 100644 --- a/src/handlers/eval/batch-evaluation/get/screen.tsx +++ b/src/handlers/eval/batch-evaluation/get/screen.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; // getBatchEvaluation already fetches the job and merges the per-session CloudWatch // results, returning `{ detail, resultsError? }`. The screen keeps both: a @@ -10,7 +10,7 @@ import { coreOptsFromCtx } from "../../../utils"; // that simply has none yet, so `resultsError` drives a warning banner (the TUI's // equivalent of the CLI's stderr warning). function useBatchEvaluationDetail({ ctx, core }: ScreenProps, id: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["batch-evaluation", opts.region, id], queryFn: () => core.eval.getBatchEvaluation(id!, opts), diff --git a/src/handlers/eval/batch-insights/get/screen.tsx b/src/handlers/eval/batch-insights/get/screen.tsx index 03340dbdf..f26d2e535 100644 --- a/src/handlers/eval/batch-insights/get/screen.tsx +++ b/src/handlers/eval/batch-insights/get/screen.tsx @@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; function useBatchInsightsDetail({ ctx, core }: ScreenProps, id: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["batch-insights", opts.region, id], queryFn: () => core.eval.getBatchInsights(id!, opts), diff --git a/src/handlers/eval/config-bundle/get/screen.tsx b/src/handlers/eval/config-bundle/get/screen.tsx index 007ebe09f..8605d8220 100644 --- a/src/handlers/eval/config-bundle/get/screen.tsx +++ b/src/handlers/eval/config-bundle/get/screen.tsx @@ -2,12 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; const DEFAULT_BRANCH = "mainline"; export function ConfigBundleGetScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const { bundleId, versionId } = useParams(); const detail = useQuery({ queryKey: ["configuration-bundle", opts.region, bundleId, versionId ?? DEFAULT_BRANCH], diff --git a/src/handlers/eval/dataset/get/screen.tsx b/src/handlers/eval/dataset/get/screen.tsx index b88386ab0..e536739ac 100644 --- a/src/handlers/eval/dataset/get/screen.tsx +++ b/src/handlers/eval/dataset/get/screen.tsx @@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; function useDatasetDetail({ ctx, core }: ScreenProps, datasetId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["dataset", opts.region, datasetId], queryFn: () => core.eval.getDataset(datasetId!, undefined, opts), diff --git a/src/handlers/eval/evaluator/get/screen.tsx b/src/handlers/eval/evaluator/get/screen.tsx index e94830d1e..690ac9cc1 100644 --- a/src/handlers/eval/evaluator/get/screen.tsx +++ b/src/handlers/eval/evaluator/get/screen.tsx @@ -4,10 +4,10 @@ import type { GetEvaluatorResponse } from "@aws-sdk/client-bedrock-agentcore-con import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; function useEvaluatorDetail({ ctx, core }: ScreenProps, evaluatorId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["evaluator", opts.region, evaluatorId], queryFn: () => core.eval.getEvaluator(evaluatorId!, opts), diff --git a/src/handlers/eval/online-eval/get/screen.tsx b/src/handlers/eval/online-eval/get/screen.tsx index bd7e7a220..26e2cedb6 100644 --- a/src/handlers/eval/online-eval/get/screen.tsx +++ b/src/handlers/eval/online-eval/get/screen.tsx @@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; function useOnlineEvalDetail({ ctx, core }: ScreenProps, configId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["online-eval", opts.region, configId], queryFn: () => core.eval.getOnlineEvaluationConfig(configId!, opts), diff --git a/src/handlers/eval/online-insight/get/screen.tsx b/src/handlers/eval/online-insight/get/screen.tsx index 11d156570..3ce4b40e1 100644 --- a/src/handlers/eval/online-insight/get/screen.tsx +++ b/src/handlers/eval/online-insight/get/screen.tsx @@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; function useOnlineInsightDetail({ ctx, core }: ScreenProps, configId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["online-insight", opts.region, configId], queryFn: () => core.eval.getOnlineInsight(configId!, opts), diff --git a/src/handlers/gateway/connector/get/screen.tsx b/src/handlers/gateway/connector/get/screen.tsx index f55eb2435..ffbcaa28e 100644 --- a/src/handlers/gateway/connector/get/screen.tsx +++ b/src/handlers/gateway/connector/get/screen.tsx @@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; export function GatewayConnectorGetScreen(props: ScreenProps) { const { gatewayId, targetId } = useParams(); - const opts = coreOptsFromCtx(props.ctx); + const opts = useCoreOpts(props.ctx); const detail = useQuery({ queryKey: ["gateway-connector", opts.region, gatewayId, targetId], queryFn: () => props.core.gateway.getGatewayConnector(gatewayId!, targetId!, opts), diff --git a/src/handlers/gateway/connector/list/screen.tsx b/src/handlers/gateway/connector/list/screen.tsx index 5273cc937..24fb4cc2f 100644 --- a/src/handlers/gateway/connector/list/screen.tsx +++ b/src/handlers/gateway/connector/list/screen.tsx @@ -1,11 +1,12 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { GatewayConnectorPicker } from "../../../../components/GatewayConnectorPicker"; import { GatewayPicker } from "../../../../components/GatewayPicker"; import type { ScreenProps } from "../../../types"; +import { useRegionNavigate } from "../../../utils"; export function GatewayConnectorListScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); if (!gatewayId) { return ( diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index 574f4f60a..a5610ad8b 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -11,10 +11,11 @@ import { } from "@aws-sdk/client-bedrock-agentcore-control"; import { cleanupScreens, + menuEntries, renderScreen, TestCoreClient, + waitFor, waitForText, - menuEntries, } from "../../testing"; afterEach(cleanupScreens); @@ -315,6 +316,31 @@ describe("Gateway Target flow", () => { await screen.press("escape"); await waitForText(screen.lastFrame, "manage Targets for an AgentCore Gateway"); }); + + // A hub reached with ?region= (from project status or a harness's linked + // rows) fetches there; its actions must keep fetching there too. + test("a Gateway hub opened with ?region= carries the region into its Target list", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + core.gateway.setListTargetsResponse({ items: [] }); + const screen = renderScreen( + `/agentcore/gateway/get/${encodeURIComponent(GATEWAY_ID)}?region=eu-west-1`, + { core }, + ); + + await waitForText(screen.lastFrame, "browse every Target"); + expect(core.gateway.calls.find((call) => call.method === "getGateway")?.args[1]).toMatchObject({ + region: "eu-west-1", + }); + + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → target → list → ${GATEWAY_ID}`); + await waitFor(() => core.gateway.calls.some((call) => call.method === "listGatewayTargets")); + expect( + core.gateway.calls.find((call) => call.method === "listGatewayTargets")?.args[3], + ).toMatchObject({ region: "eu-west-1" }); + }); }); describe("Gateway Connector flow", () => { diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx index 4e4e0b0b1..8f0271a94 100644 --- a/src/handlers/gateway/get/screen.tsx +++ b/src/handlers/gateway/get/screen.tsx @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; -import { useCoreOpts } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) { const opts = useCoreOpts(ctx); @@ -15,7 +15,7 @@ function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefi } export function GatewayGetScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { gatewayId } = useParams(); const detail = useGatewayDetail(props, gatewayId); diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 6cdd905e9..6875c47e6 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -5,7 +5,7 @@ import cliTruncate from "cli-truncate"; import { Box, Text, useInput, useWindowSize } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { GatewayPicker } from "../../../components/GatewayPicker"; import { Layout } from "../../../components/Layout"; import { MultilineInput } from "../../../components/MultilineInput"; @@ -15,7 +15,7 @@ import { Spinner } from "../../../components/ui/spinner"; import { TextInput } from "../../../components/ui/text-input"; import { classifyStreamingResponse } from "../../../io"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; import type { GatewayInvokeRequest, GatewayInvokeResponse } from "../types"; import { GatewayInvokeLaunchContextKey, type GatewayInvokeLaunchContext } from "./launchContext"; import { normalizeGatewayInvokeRequest } from "./request"; @@ -140,7 +140,7 @@ function PathEditor({ export function GatewayInvokeScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const launchContext = props.ctx.value(GatewayInvokeLaunchContextKey); const initialContext = launchContext?.gatewayId === gatewayId ? launchContext : undefined; @@ -164,8 +164,8 @@ type GatewayInvokeConsoleProps = ScreenProps & { }; function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayInvokeConsoleProps) { - const navigate = useNavigate(); - const opts = coreOptsFromCtx(ctx); + const navigate = useRegionNavigate(); + const opts = useCoreOpts(ctx); const { columns, rows } = useWindowSize(); const [targetGatewayId, setTargetGatewayId] = useState(gatewayId); const [pickingGateway, setPickingGateway] = useState(false); diff --git a/src/handlers/gateway/list/screen.tsx b/src/handlers/gateway/list/screen.tsx index 05828d2fb..0fa8e9135 100644 --- a/src/handlers/gateway/list/screen.tsx +++ b/src/handlers/gateway/list/screen.tsx @@ -1,9 +1,9 @@ -import { useNavigate } from "react-router"; import { GatewayPicker } from "../../../components/GatewayPicker"; import type { ScreenProps } from "../../types"; +import { useRegionNavigate } from "../../utils"; export function GatewayListScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); return ( core.gateway.getGateway(gatewayId, opts), diff --git a/src/handlers/gateway/rule/get/screen.tsx b/src/handlers/gateway/rule/get/screen.tsx index b85dc7dab..ce29c92fb 100644 --- a/src/handlers/gateway/rule/get/screen.tsx +++ b/src/handlers/gateway/rule/get/screen.tsx @@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; export function GatewayRuleGetScreen(props: ScreenProps) { const { gatewayId, ruleId } = useParams(); - const opts = coreOptsFromCtx(props.ctx); + const opts = useCoreOpts(props.ctx); const detail = useQuery({ queryKey: ["gateway-rule", opts.region, gatewayId, ruleId], queryFn: () => props.core.gateway.getGatewayRule(gatewayId!, ruleId!, opts), diff --git a/src/handlers/gateway/rule/list/screen.tsx b/src/handlers/gateway/rule/list/screen.tsx index 787317271..d8bf47e5b 100644 --- a/src/handlers/gateway/rule/list/screen.tsx +++ b/src/handlers/gateway/rule/list/screen.tsx @@ -1,11 +1,12 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { GatewayPicker } from "../../../../components/GatewayPicker"; import { GatewayRulePicker } from "../../../../components/GatewayRulePicker"; import type { ScreenProps } from "../../../types"; +import { useRegionNavigate } from "../../../utils"; export function GatewayRuleListScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); if (!gatewayId) { return ( diff --git a/src/handlers/gateway/target/list/screen.tsx b/src/handlers/gateway/target/list/screen.tsx index 94f833447..8c8ce3522 100644 --- a/src/handlers/gateway/target/list/screen.tsx +++ b/src/handlers/gateway/target/list/screen.tsx @@ -1,11 +1,12 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { GatewayPicker } from "../../../../components/GatewayPicker"; import { GatewayTargetPicker } from "../../../../components/GatewayTargetPicker"; import type { ScreenProps } from "../../../types"; +import { useRegionNavigate } from "../../../utils"; export function GatewayTargetListScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); if (!gatewayId) { return ( diff --git a/src/handlers/harness/delete/screen.tsx b/src/handlers/harness/delete/screen.tsx index f939e3ab6..6b05a0d34 100644 --- a/src/handlers/harness/delete/screen.tsx +++ b/src/handlers/harness/delete/screen.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; import { HarnessPicker } from "../../../components/HarnessPicker"; import { ConfirmAction } from "../../../components/ConfirmAction"; @@ -9,7 +9,7 @@ import { ConfirmAction } from "../../../components/ConfirmAction"; // renders a harness picker; with one it shows the harness summary and asks for // confirmation before calling DeleteHarness. export function HarnessDeleteScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { harnessId } = useParams(); if (!harnessId) { @@ -26,8 +26,8 @@ export function HarnessDeleteScreen(props: ScreenProps) { } function DeleteConfirm({ ctx, core, harnessId }: ScreenProps & { harnessId: string }) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); + const opts = useCoreOpts(ctx); + const navigate = useRegionNavigate(); const detail = useQuery({ queryKey: ["harness", opts.region, harnessId], diff --git a/src/handlers/harness/endpoint/create/screen.tsx b/src/handlers/harness/endpoint/create/screen.tsx index 765653ccb..f31976d4f 100644 --- a/src/handlers/harness/endpoint/create/screen.tsx +++ b/src/handlers/harness/endpoint/create/screen.tsx @@ -1,15 +1,16 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { HarnessPicker } from "../../../../components/HarnessPicker"; import { EndpointWizard } from "../../../../components/EndpointWizard"; import { useFinishFlow } from "../../../../components/useFinishFlow"; +import { useRegionNavigate } from "../../../utils"; // HarnessCreateEndpointScreen is the interactive endpoint create flow. Without // a `:harnessId` route value it renders a harness picker; with one it runs the // endpoint wizard (name → version → review) ending in a // CreateHarnessEndpoint call. Success lands on the endpoint's detail. export function HarnessCreateEndpointScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const finishFlow = useFinishFlow("/agentcore/harness/endpoint"); const { harnessId } = useParams(); diff --git a/src/handlers/harness/endpoint/delete/screen.tsx b/src/handlers/harness/endpoint/delete/screen.tsx index d76618650..51a129a12 100644 --- a/src/handlers/harness/endpoint/delete/screen.tsx +++ b/src/handlers/harness/endpoint/delete/screen.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../../utils"; import { HarnessPicker } from "../../../../components/HarnessPicker"; import { HarnessEndpointPicker } from "../../../../components/HarnessEndpointPicker"; import { ConfirmAction } from "../../../../components/ConfirmAction"; @@ -11,7 +11,7 @@ import { useFinishFlow } from "../../../../components/useFinishFlow"; // from a harness picker to an endpoint picker to a confirmation, then calls // DeleteHarnessEndpoint. export function HarnessDeleteEndpointScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { harnessId, endpointName } = useParams(); if (!harnessId) { @@ -44,7 +44,7 @@ function DeleteConfirm({ harnessId, endpointName, }: ScreenProps & { harnessId: string; endpointName: string }) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const finishFlow = useFinishFlow("/agentcore/harness/endpoint"); const detail = useQuery({ diff --git a/src/handlers/harness/endpoint/get/screen.tsx b/src/handlers/harness/endpoint/get/screen.tsx index 4884e5520..bf74db8be 100644 --- a/src/handlers/harness/endpoint/get/screen.tsx +++ b/src/handlers/harness/endpoint/get/screen.tsx @@ -1,13 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; import { JsonDetail } from "../../../../components/JsonDetail"; // HarnessGetEndpointScreen shows one endpoint's full definition as scrollable // JSON. The harness ID and endpoint name come from the route path values. export function HarnessGetEndpointScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const { harnessId, endpointName } = useParams(); const detail = useQuery({ diff --git a/src/handlers/harness/endpoint/list/screen.tsx b/src/handlers/harness/endpoint/list/screen.tsx index 29fbd76e9..83df07c76 100644 --- a/src/handlers/harness/endpoint/list/screen.tsx +++ b/src/handlers/harness/endpoint/list/screen.tsx @@ -1,13 +1,14 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { HarnessPicker } from "../../../../components/HarnessPicker"; import { HarnessEndpointPicker } from "../../../../components/HarnessEndpointPicker"; +import { useRegionNavigate } from "../../../utils"; // HarnessListEndpointsScreen lists a harness's endpoints. Without a `:harnessId` // route value it renders a harness picker first; with one it lists that // harness's endpoints, and selecting an endpoint opens its JSON detail. export function HarnessListEndpointsScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { harnessId } = useParams(); if (!harnessId) { diff --git a/src/handlers/harness/endpoint/update/screen.tsx b/src/handlers/harness/endpoint/update/screen.tsx index 580ebaaa0..c4c9bd99c 100644 --- a/src/handlers/harness/endpoint/update/screen.tsx +++ b/src/handlers/harness/endpoint/update/screen.tsx @@ -1,8 +1,8 @@ import { Text } from "ink"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../../utils"; import { HarnessPicker } from "../../../../components/HarnessPicker"; import { HarnessEndpointPicker } from "../../../../components/HarnessEndpointPicker"; import { EndpointWizard } from "../../../../components/EndpointWizard"; @@ -14,7 +14,7 @@ import { useFinishFlow } from "../../../../components/useFinishFlow"; // harness, pick the endpoint, then run the endpoint wizard prefilled with the // endpoint's current target version, ending in an UpdateHarnessEndpoint call. export function HarnessUpdateEndpointScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { harnessId, endpointName } = useParams(); if (!harnessId) { @@ -47,7 +47,7 @@ function UpdateWizard({ harnessId, endpointName, }: ScreenProps & { harnessId: string; endpointName: string }) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const finishFlow = useFinishFlow("/agentcore/harness/endpoint"); // The wizard seeds its form state once on mount, so it renders only after the diff --git a/src/handlers/harness/exec/screen.tsx b/src/handlers/harness/exec/screen.tsx index 6130b2ee8..bea300a63 100644 --- a/src/handlers/harness/exec/screen.tsx +++ b/src/handlers/harness/exec/screen.tsx @@ -1,7 +1,8 @@ -import { useNavigate, useParams, useSearchParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import type { ScreenProps } from "../../types"; import { HarnessPicker } from "../../../components/HarnessPicker"; import { HarnessChat } from "../invoke/screen"; +import { useRegionNavigate } from "../../utils"; // HarnessExecScreen is `harness exec` in the TUI: the same chat screen as // invoke, but starting in exec mode ($ prompt, enter runs a shell command in @@ -11,7 +12,7 @@ import { HarnessChat } from "../invoke/screen"; export function HarnessExecScreen(props: ScreenProps) { const { harnessId, sessionId } = useParams(); const [search] = useSearchParams(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); if (!harnessId) { return ( diff --git a/src/handlers/harness/get/get.screen.test.tsx b/src/handlers/harness/get/get.screen.test.tsx index be98aab74..de169d4a8 100644 --- a/src/handlers/harness/get/get.screen.test.tsx +++ b/src/handlers/harness/get/get.screen.test.tsx @@ -176,6 +176,30 @@ describe("harness hub screen", () => { await waitFor(() => core.harness.calls.some((c) => c.method === "listHarnesses")); r.unmount(); }); + + // A hub reached with ?region= (from project status) fetches there; its + // actions must keep fetching there too. + test("a hub opened with ?region= carries the region into its endpoint list", async () => { + const core = new TestCoreClient(); + core.harness.setGetResponse(getResponse()); + core.harness.setListEndpointsResponse({ endpoints: [] }); + const r = renderScreen("/agentcore/harness/get/MyHarness-abc123?region=us-west-2", { core }); + + await waitForText(r.lastFrame, "detail"); + const get = core.harness.calls.find((c) => c.method === "getHarness")!; + expect(get.args[1]).toMatchObject({ region: "us-west-2" }); + + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → harness → endpoint → list → MyHarness-abc123"); + await waitFor(() => core.harness.calls.some((c) => c.method === "listHarnessEndpoints")); + const list = core.harness.calls.find((c) => c.method === "listHarnessEndpoints")!; + const options = list.args.find( + (arg) => typeof arg === "object" && arg !== null && "region" in arg, + ); + expect(options).toMatchObject({ region: "us-west-2" }); + r.unmount(); + }); }); // The linked resources live in us-west-2 while the test context's ambient @@ -482,6 +506,26 @@ describe("harness hub linked resources", () => { expect(r.lastFrame()).toContain("collapse/expand"); r.unmount(); }); + + test("a linked runtime's region follows it into the runtime's detail JSON", async () => { + const { core, r } = linkedHubScreen(); + + await waitForText(r.lastFrame, "linked resources"); + await focusTree(r); + await r.press("return"); + await waitForText(r.lastFrame, `agentcore → runtime → get → ${RUNTIME_ID}`); + await waitForText(r.lastFrame, "show the full JSON definition"); + // invoke → shell → endpoints → versions → detail + for (let press = 0; press < 4; press++) await r.press("down"); + await r.press("return"); + + await waitForText(r.lastFrame, `agentcore → runtime → get → ${RUNTIME_ID} → json`); + await waitForText(r.lastFrame, '"agentRuntimeId"'); + const fetches = core.runtime.calls.filter(({ method }) => method === "getRuntime"); + expect(fetches.length).toBeGreaterThanOrEqual(2); + for (const fetch of fetches) expect(fetch.args[1]).toMatchObject({ region: LINK_REGION }); + r.unmount(); + }); }); describe("buildHarnessLinkNodes", () => { diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index e8a9f1cdf..512b67db9 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { Harness, HarnessTool } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../../types"; -import { useCoreOpts } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; import { credentialProviderTypeFromArn, parseArn, @@ -288,7 +288,7 @@ function useHarnessDetail({ ctx, core }: ScreenProps, harnessId: string | undefi // the API key providers behind the model and git-backed skills. Enter on a // linked row opens that resource's own hub in the region its ARN names. export function HarnessGetScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { harnessId } = useParams(); const { query: detail, region } = useHarnessDetail(props, harnessId); const harness = detail.data?.harness; diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index 838e85668..2726a4f36 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams, useSearchParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; import { HarnessPicker } from "../../../components/HarnessPicker"; import { HarnessEndpointPicker } from "../../../components/HarnessEndpointPicker"; import { Layout } from "../../../components/Layout"; @@ -37,7 +37,7 @@ const theme = darkTheme; export function HarnessInvokeScreen(props: ScreenProps) { const { harnessId, sessionId } = useParams(); const [search] = useSearchParams(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); if (!harnessId) { return ( @@ -95,9 +95,9 @@ export function HarnessChat({ variant, onBack, }: HarnessChatProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const { columns, rows } = useWindowSize(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const detail = useQuery({ queryKey: ["harness", opts.region, harnessId], diff --git a/src/handlers/harness/list/screen.tsx b/src/handlers/harness/list/screen.tsx index d31282677..9ae419b00 100644 --- a/src/handlers/harness/list/screen.tsx +++ b/src/handlers/harness/list/screen.tsx @@ -1,11 +1,11 @@ -import { useNavigate } from "react-router"; import type { ScreenProps } from "../../types"; import { HarnessPicker } from "../../../components/HarnessPicker"; +import { useRegionNavigate } from "../../utils"; // HarnessListScreen lists the caller's harnesses in a table; selecting one pushes // to HarnessGetScreen with the harness ID as a path value. export function HarnessListScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); return ( { @@ -41,8 +41,8 @@ interface EventPickerProps extends ScreenProps { } function EventPicker({ ctx, core, memoryId, actorId, sessionId }: EventPickerProps) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); + const opts = useCoreOpts(ctx); + const navigate = useRegionNavigate(); return ( { actorId: string; @@ -34,7 +34,7 @@ export function MemoryActorPicker({ onSelect, onBack, }: MemoryActorPickerProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return ( { expect(screen.lastFrame()).toContain("agentcore → memory → get → memory-1"); expect(screen.lastFrame()).not.toContain("→ json"); }); + + // A hub reached with ?region= (from project status or a harness's linked + // rows) fetches there; its actions must keep fetching there too. + test("a hub opened with ?region= carries the region into its actor list", async () => { + const core = new TestCoreClient(); + core.memory.setGetResponse(getMemoryOutput()); + core.memory.setListActorsResponse({ actorSummaries: [] }); + const screen = renderScreen("/agentcore/memory/get/memory-1?region=eu-west-1", { core }); + + await waitForText(screen.lastFrame, "list this Memory's events"); + expect(core.memory.calls.find((call) => call.method === "getMemory")?.args[2]).toMatchObject({ + region: "eu-west-1", + }); + + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "choose an actor to list sessions for"); + await waitFor(() => core.memory.calls.some((call) => call.method === "listActors")); + expect(core.memory.calls.find((call) => call.method === "listActors")?.args[1]).toMatchObject({ + region: "eu-west-1", + }); + }); }); diff --git a/src/handlers/memory/record/get/screen.tsx b/src/handlers/memory/record/get/screen.tsx index c3ed7444d..274ced5f1 100644 --- a/src/handlers/memory/record/get/screen.tsx +++ b/src/handlers/memory/record/get/screen.tsx @@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; export function MemoryRecordGetScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const { memoryId, recordId } = useParams(); const detail = useQuery({ queryKey: ["memory-record", opts.region, memoryId, recordId], diff --git a/src/handlers/memory/record/list/screen.tsx b/src/handlers/memory/record/list/screen.tsx index 183139263..b191dcf2a 100644 --- a/src/handlers/memory/record/list/screen.tsx +++ b/src/handlers/memory/record/list/screen.tsx @@ -1,7 +1,7 @@ import type { MemoryContent, MemoryRecordSummary } from "@aws-sdk/client-bedrock-agentcore"; import { Box, Text, useInput } from "ink"; import { useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { FormRadioGroup, type FormRadioOption } from "../../../../components/FormRadioGroup"; import { FormTextInput } from "../../../../components/FormTextInput"; import { Layout } from "../../../../components/Layout"; @@ -11,7 +11,7 @@ import { formatTimestamp } from "../../../../components/formatTimestamp"; import { darkTheme } from "../../../../components/ui/_core"; import type { DataTableColumn } from "../../../../components/ui/data-table"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../../utils"; type RecordScopeKind = "namespace" | "namespace-path"; @@ -65,7 +65,7 @@ interface MemoryRecordScopeScreenProps { } function MemoryRecordScopeScreen({ memoryId }: MemoryRecordScopeScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const [focusedIndex, setFocusedIndex] = useState(0); const [scope, setScope] = useState(""); const [submitted, setSubmitted] = useState(false); @@ -158,8 +158,8 @@ interface MemoryRecordPickerProps extends ScreenProps { } function MemoryRecordPicker({ ctx, core, memoryId, scopeKind, scope }: MemoryRecordPickerProps) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); + const opts = useCoreOpts(ctx); + const navigate = useRegionNavigate(); return ( { await screen.press("escape"); await waitForText(screen.lastFrame, "manage an AgentCore project"); }); + + // A detail page opened from status fetches in the target's region, but its + // actions are routes of their own: without the override travelling with + // them the JSON view (and every list) would fetch in the ambient region and + // fail to find the resource. + test("the target region follows the Runtime into its detail JSON", async () => { + const value = core(); + const screen = renderStatus(value); + + await waitForGroup(screen); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + // invoke → shell → endpoints → versions → detail + for (let press = 0; press < 4; press++) await screen.press("down"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "agentcore → runtime → get → " + RUNTIME_ID + " → json"); + await waitForText(screen.lastFrame, '"agentRuntimeId"'); + const fetches = value.runtime.calls.filter(({ method }) => method === "getRuntime"); + expect(fetches.length).toBeGreaterThanOrEqual(2); + for (const fetch of fetches) expect(fetch.args[1]).toMatchObject({ region: TARGET.region }); + }); + + test("the target region follows the Runtime into its endpoint list", async () => { + const value = core(); + value.runtime.setListEndpointsResponse({ runtimeEndpoints: [] }); + const screen = renderStatus(value); + + await waitForGroup(screen); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "agentcore → runtime → endpoint → list → " + RUNTIME_ID); + await waitFor(() => + value.runtime.calls.some(({ method }) => method === "listRuntimeEndpoints"), + ); + const call = value.runtime.calls.find(({ method }) => method === "listRuntimeEndpoints")!; + expect(call.args[0]).toBe(RUNTIME_ID); + expect(call.args[3]).toMatchObject({ region: TARGET.region }); + }); + + test("the target region follows the Memory into its actor list", async () => { + const value = core(); + value.memory.setListActorsResponse({ actorSummaries: [] }); + const screen = renderStatus(value); + + await waitForGroup(screen); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "list this Memory's events"); + await screen.press("down"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "choose an actor to list sessions for"); + await waitFor(() => value.memory.calls.some(({ method }) => method === "listActors")); + const call = value.memory.calls.find(({ method }) => method === "listActors")!; + expect(call.args[0]).toMatchObject({ memoryId: MEMORY_ID }); + expect(call.args[1]).toMatchObject({ region: TARGET.region }); + }); }); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index eec3e0ad2..6f14a20c0 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -1,16 +1,16 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../../utils"; function useRuntimeEndpointDetail( { ctx, core }: ScreenProps, runtimeId: string | undefined, qualifier: string | undefined, ) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["runtime-endpoint", opts.region, runtimeId, qualifier], queryFn: () => core.runtime.getRuntimeEndpoint(runtimeId!, qualifier!, opts), @@ -24,7 +24,7 @@ function endpointPath(runtimeId: string, qualifier: string, suffix?: string): st } export function RuntimeGetEndpointScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { runtimeId, qualifier } = useParams(); const detail = useRuntimeEndpointDetail(props, runtimeId, qualifier); const endpoint = detail.data; diff --git a/src/handlers/runtime/endpoint/list/screen.tsx b/src/handlers/runtime/endpoint/list/screen.tsx index bdfa79865..809630fdd 100644 --- a/src/handlers/runtime/endpoint/list/screen.tsx +++ b/src/handlers/runtime/endpoint/list/screen.tsx @@ -1,10 +1,11 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { RuntimeEndpointPicker } from "../../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../../components/RuntimePicker"; +import { useRegionNavigate } from "../../../utils"; export function RuntimeListEndpointsScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { runtimeId } = useParams(); if (!runtimeId) { diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index 3b1862a6c..eb6a981c7 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; -import { useCoreOpts } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; const ACTIONS = [ { @@ -45,7 +45,7 @@ function useRuntimeDetail({ ctx, core }: ScreenProps, runtimeId: string | undefi } export function RuntimeGetScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { runtimeId } = useParams(); const detail = useRuntimeDetail(props, runtimeId); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 625edd52a..c1ef05d80 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -3,11 +3,11 @@ import { ServiceException } from "@smithy/core/client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { useQuery } from "@tanstack/react-query"; -import { useLocation, useNavigate, useParams } from "react-router"; +import { useLocation, useParams } from "react-router"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import cliTruncate from "cli-truncate"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts, useRegionNavigate } from "../../utils"; import { Layout } from "../../../components/Layout"; import { MultilineInput } from "../../../components/MultilineInput"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; @@ -95,7 +95,7 @@ function ErrorBlock({ details }: { details: ErrorDetails }) { export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); const location = useLocation(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const launchContext = props.ctx.value(RuntimeInvokeLaunchContextKey); const initialContext = launchContext?.runtimeId === runtimeId ? launchContext : undefined; const returnOnEscape = (location.state as RuntimeInvokeLocationState | null)?.returnOnEscape; @@ -157,8 +157,8 @@ export function RuntimeInvokeConsole({ returnOnEscape, onBack, }: RuntimeInvokeConsoleProps) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); + const opts = useCoreOpts(ctx); + const navigate = useRegionNavigate(); const { columns, rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); const [targetPicker, setTargetPicker] = useState(null); diff --git a/src/handlers/runtime/list/screen.tsx b/src/handlers/runtime/list/screen.tsx index 90a78493d..5037888b3 100644 --- a/src/handlers/runtime/list/screen.tsx +++ b/src/handlers/runtime/list/screen.tsx @@ -1,9 +1,9 @@ -import { useNavigate } from "react-router"; import type { ScreenProps } from "../../types"; import { RuntimePicker } from "../../../components/RuntimePicker"; +import { useRegionNavigate } from "../../utils"; export function RuntimeListScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); return ( { return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); }); }); + + // A hub reached with ?region= (from project status or a harness's linked + // rows) fetches there; its actions must keep fetching there too. + test("a hub opened with ?region= carries the region into its actions", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + core.runtime.setListEndpointsResponse({ runtimeEndpoints: [] }); + const r = renderScreen("/agentcore/runtime/get/runtime-123?region=eu-west-1", { core }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + expect(core.runtime.calls.find((call) => call.method === "getRuntime")?.args[1]).toMatchObject({ + region: "eu-west-1", + }); + + await r.press("down"); + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeEndpoints")); + expect( + core.runtime.calls.find((call) => call.method === "listRuntimeEndpoints")?.args[3], + ).toMatchObject({ region: "eu-west-1" }); + + await r.press("escape"); + await waitForText(r.lastFrame, "show the full JSON definition"); + for (let index = 0; index < 4; index += 1) await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123 → json"); + await waitForText(r.lastFrame, '"agentRuntimeId"'); + const fetches = core.runtime.calls.filter((call) => call.method === "getRuntime"); + expect(fetches.length).toBeGreaterThanOrEqual(2); + for (const fetch of fetches) expect(fetch.args[1]).toMatchObject({ region: "eu-west-1" }); + }); }); diff --git a/src/handlers/runtime/shell/screen.tsx b/src/handlers/runtime/shell/screen.tsx index 6687b3c46..b16be5a9e 100644 --- a/src/handlers/runtime/shell/screen.tsx +++ b/src/handlers/runtime/shell/screen.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useApp, useStderr, useStdin, useStdout } from "ink"; -import { useLocation, useNavigate, useParams } from "react-router"; +import { useLocation, useParams } from "react-router"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; import { Spinner } from "../../../components/ui/spinner"; @@ -8,6 +8,7 @@ import { SilentCLIError } from "../../../errors"; import type { ScreenProps } from "../../types"; import { RuntimeShellLaunchContextKey } from "./launchContext"; import { runRuntimeShell } from "./operation"; +import { useRegionNavigate } from "../../utils"; type RuntimeShellLocationState = { returnOnEscape?: boolean; @@ -20,7 +21,7 @@ const shellPath = (...parts: string[]) => export function RuntimeShellScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); const location = useLocation(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const locationState = location.state as RuntimeShellLocationState | null; const returnOnEscape = locationState?.returnOnEscape; @@ -85,7 +86,7 @@ function RuntimeShellHandoff({ const { stdin } = useStdin(); const { stdout } = useStdout(); const { stderr } = useStderr(); - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const requested = useRef(false); const launchContext = ctx.value(RuntimeShellLaunchContextKey); const initialContext = launchContext?.runtimeId === runtimeId ? launchContext : undefined; diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx index b74341947..9b17e6aa4 100644 --- a/src/handlers/runtime/version/get/screen.tsx +++ b/src/handlers/runtime/version/get/screen.tsx @@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); const { runtimeId, version } = useParams(); const detail = useQuery({ queryKey: ["runtime-version", opts.region, runtimeId, version], diff --git a/src/handlers/runtime/version/list/screen.tsx b/src/handlers/runtime/version/list/screen.tsx index bf9484063..8d5e09aeb 100644 --- a/src/handlers/runtime/version/list/screen.tsx +++ b/src/handlers/runtime/version/list/screen.tsx @@ -1,10 +1,11 @@ -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { RuntimePicker } from "../../../../components/RuntimePicker"; import { RuntimeVersionPicker } from "../../../../components/RuntimeVersionPicker"; +import { useRegionNavigate } from "../../../utils"; export function RuntimeListVersionsScreen(props: ScreenProps) { - const navigate = useNavigate(); + const navigate = useRegionNavigate(); const { runtimeId } = useParams(); if (!runtimeId) { diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index 6b354f52b..8d5a47363 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseJsonArrayFlag, parseJsonObjectFlag, parseTags } from "./utils"; +import { parseJsonArrayFlag, parseJsonObjectFlag, parseTags, withRegion } from "./utils"; describe("structured JSON flags", () => { test("parses object and array values", () => { @@ -65,3 +65,31 @@ describe("parseTags", () => { expect(() => parseTags(["noequals"])).toThrow("expected key=value"); }); }); + +describe("withRegion", () => { + test("appends the region as a query string", () => { + expect(withRegion("/agentcore/runtime/get/rt-1", "eu-west-1")).toBe( + "/agentcore/runtime/get/rt-1?region=eu-west-1", + ); + }); + + test("joins an existing query string", () => { + expect(withRegion("/agentcore/harness/exec/h-1?qualifier=PROD", "eu-west-1")).toBe( + "/agentcore/harness/exec/h-1?qualifier=PROD®ion=eu-west-1", + ); + }); + + test("keeps a region the route already names", () => { + expect(withRegion("/agentcore/memory/get/m-1?region=us-west-2", "eu-west-1")).toBe( + "/agentcore/memory/get/m-1?region=us-west-2", + ); + }); + + test("leaves the route alone without an override", () => { + expect(withRegion("/agentcore/runtime/get/rt-1", null)).toBe("/agentcore/runtime/get/rt-1"); + expect(withRegion("/agentcore/runtime/get/rt-1", undefined)).toBe( + "/agentcore/runtime/get/rt-1", + ); + expect(withRegion("/agentcore/runtime/get/rt-1", "")).toBe("/agentcore/runtime/get/rt-1"); + }); +}); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 55b79eb93..44fecb658 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,4 +1,5 @@ -import { useSearchParams } from "react-router"; +import { useCallback } from "react"; +import { useNavigate, useSearchParams, type NavigateOptions } from "react-router"; import type { Context } from "../router"; import type z from "zod"; import type { CoreOptions } from "../core/types"; @@ -32,6 +33,38 @@ export function useCoreOpts(ctx: Context): CoreOptions { return region ? { ...opts, region } : opts; } +// withRegion carries a region override onto a route as the ?region= query +// useCoreOpts reads on the other side. A route that already names a region +// keeps it (a linked resource's own region beats the current screen's), and +// without an override the route comes back unchanged. +export function withRegion(to: string, region: string | null | undefined): string { + if (!region) return to; + const [path = "", query = ""] = to.split("?", 2); + const params = new URLSearchParams(query); + if (params.has("region")) return to; + params.set("region", region); + return `${path}?${params.toString()}`; +} + +// useRegionNavigate is useNavigate for screens that may have been opened with +// a ?region= override. A hub reached through project status or a harness's +// linked rows fetches in that region, but its actions, the lists they open and +// the rows those lists forward to are all routes of their own — so string +// routes carry the override forward and the whole flow keeps fetching where +// the resource lives. History moves (navigate(-1)) pass through untouched. +export function useRegionNavigate(): (to: string | number, options?: NavigateOptions) => void { + const navigate = useNavigate(); + const [search] = useSearchParams(); + const region = search.get("region"); + return useCallback( + (to: string | number, options?: NavigateOptions) => { + if (typeof to === "number") navigate(to); + else navigate(withRegion(to, region), options); + }, + [navigate, region], + ); +} + // parseJsonFlag parses a flag's raw string as JSON, typed as the API structure // the flag mirrors. Structured API parameters (model/tools/memory/...) are // accepted as JSON documents rather than exploded into dozens of leaf flags;