Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ const archivedContextBannerElement: ReactNode = (
const environmentGoneContextBannerElement: ReactNode = (
<ThreadPromptContextBanner
archivedSection={null}
environmentGoneSection={{ status: "destroyed" }}
environmentGoneSection={{ status: "destroyed", onHandoff: noop }}
gitSection={null}
gitSectionPending={false}
parentThreadSection={null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,10 +646,12 @@ const archivedFixture: ThreadPromptArchivedSection = {

const destroyedEnvironmentFixture: ThreadPromptEnvironmentGoneSection = {
status: "destroyed",
onHandoff: noop,
};

const destroyingEnvironmentFixture: ThreadPromptEnvironmentGoneSection = {
status: "destroying",
onHandoff: noop,
};

export function Overview() {
Expand Down Expand Up @@ -689,14 +691,14 @@ export function Overview() {
/>
</StoryRow>
<StoryRow
label="environment destroyed"
hint="environment-gone row suppresses git/childThreads"
label="environment archived"
hint="archived-environment row suppresses git/childThreads"
>
<Row environmentGone={destroyedEnvironmentFixture} mergeBase={null} />
</StoryRow>
<StoryRow
label="environment destroying + child thread"
hint="environment-gone row plus parent context"
label="environment archiving + child thread"
hint="archiving-environment row plus parent context"
>
<Row
environmentGone={destroyingEnvironmentFixture}
Expand All @@ -705,8 +707,8 @@ export function Overview() {
/>
</StoryRow>
<StoryRow
label="environment gone (with other context, all suppressed)"
hint="gone environment takes precedence — git/child work are hidden"
label="environment archived (with other context, all suppressed)"
hint="archived environment takes precedence — git/child work are hidden"
>
<Row
environmentGone={destroyedEnvironmentFixture}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ describe("ThreadPromptContextBanner", () => {
/>,
);

expect(markup).toContain("Environment is unavailable");
expect(markup).toContain("This thread can&#x27;t run any more work.");
expect(markup).toContain("Environment archived");
expect(markup).toContain("This environment has been archived.");
expect(markup).not.toContain("to keep working");
expect(markup).toContain('role="status"');
expect(markup).not.toContain("<button");
expect(markup).not.toContain("Provision");
Expand All @@ -115,10 +116,10 @@ describe("ThreadPromptContextBanner", () => {
expectedLabel: "Thread is archived",
},
{
label: "environment gone",
label: "environment archived",
archivedSection: null,
environmentGoneSection: { status: "destroyed" as const },
expectedLabel: "Environment is unavailable",
expectedLabel: "Environment archived",
},
])(
"keeps the $label read-only status visible in compact mode",
Expand Down Expand Up @@ -150,6 +151,88 @@ describe("ThreadPromptContextBanner", () => {
},
);

it("renders an enabled handoff action once the environment is destroyed", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={null}
environmentGoneSection={{
status: "destroyed",
onHandoff: noop,
}}
parentThreadSection={null}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>,
);

expect(markup).toContain("Continue in new thread");
expect(markup).toContain(
"This environment has been archived. Continue in a new thread to keep working.",
);
expect(markup).toContain("<button");
expect(markup).not.toContain('disabled=""');
});

it("shows a disabled handoff action while the environment is destroying", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={null}
environmentGoneSection={{
status: "destroying",
onHandoff: noop,
}}
parentThreadSection={null}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>,
);

expect(markup).toContain("Archiving environment...");
expect(markup).toContain("Continue in new thread");
expect(markup).toContain(
"This environment is being archived. Continue in a new thread when cleanup finishes.",
);
expect(markup).toContain('disabled=""');
});

it("prioritizes destroyed-environment handoff over unarchiving", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={{
archivedAt: 1_731_456_000_000,
onUnarchive: noop,
}}
environmentGoneSection={{ status: "destroyed", onHandoff: noop }}
parentThreadSection={{
parentThreadTitle: "Parent thread",
href: "/threads/thr_parent",
relationship: "parent",
}}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>
</MemoryRouter>,
);

expect(markup).toContain("Environment archived");
expect(markup).toContain("Continue in new thread");
expect(markup).not.toContain("Thread is archived");
expect(markup).not.toContain(">Unarchive<");
});

it("labels a standalone pull request without non-actionable attention text", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export interface ThreadPromptArchivedSection {
*/
export interface ThreadPromptEnvironmentGoneSection {
status: Extract<EnvironmentStatus, "destroying" | "destroyed">;
/**
* Hands the surviving thread context off to a new thread. Enabled once the old
* environment is fully gone (`destroyed`); while `destroying` the action shows
* a disabled "Continue in new thread" state. Omitted when no safe environment
* target can be derived.
*/
onHandoff?: () => void;
}

/**
Expand Down Expand Up @@ -193,9 +200,22 @@ const KIND_PREFIX: Record<WorkspaceChangedFilesSection["kind"], string> = {
};

const ARCHIVED_THREAD_STATUS_LABEL = "Thread is archived";
const ENVIRONMENT_GONE_STATUS_LABEL = "Environment is unavailable";
const ENVIRONMENT_GONE_ARIA_LABEL =
"Environment is unavailable. This thread can't run any more work.";
const ENVIRONMENT_GONE_STATUS_COPY: Record<
ThreadPromptEnvironmentGoneSection["status"],
{ ariaLabel: string; handoffInstruction: string; label: string }
> = {
destroying: {
ariaLabel: "This environment is being archived.",
handoffInstruction:
"Continue in a new thread when cleanup finishes.",
label: "Archiving environment...",
},
destroyed: {
ariaLabel: "This environment has been archived.",
handoffInstruction: "Continue in a new thread to keep working.",
label: "Environment archived",
},
};
const PROMPT_BANNER_ACTION_FILL_CLASS = "bg-background shadow-xs";
const PROMPT_BANNER_ACTION_INTERACTIVE_CLASS =
"cursor-pointer text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60";
Expand Down Expand Up @@ -285,8 +305,7 @@ function SectionToggleButton({
// icon — the icons' own internal padding provides enough separation,
// and a gap here makes the pair look untethered.
label !== null && label !== undefined ? "gap-1.5" : "gap-0",
!active &&
(isExpanded ? "text-foreground" : "text-muted-foreground"),
!active && (isExpanded ? "text-foreground" : "text-muted-foreground"),
)}
>
{icon}
Expand Down Expand Up @@ -317,9 +336,7 @@ function SectionToggleButton({
<Icon
name="ChevronDown"
className={cn(
active
? activityIconClass("active")
: "text-subtle-foreground",
active ? activityIconClass("active") : "text-subtle-foreground",
"size-3.5 shrink-0 transition-transform duration-200",
isExpanded && "rotate-180",
)}
Expand Down Expand Up @@ -526,6 +543,26 @@ function PullRequestReadyTextAction({
);
}

function EnvironmentHandoffTextAction({
status,
onHandoff,
}: {
status: Extract<EnvironmentStatus, "destroying" | "destroyed">;
onHandoff: () => void;
}) {
const cleaningUp = status === "destroying";
return (
<button
type="button"
onClick={onHandoff}
disabled={cleaningUp}
className="rounded px-1 py-0.5 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
>
Continue in new thread
</button>
);
}

const PULL_REQUEST_MERGE_ACTIONS: readonly {
method: PullRequestMergeMethod;
label: string;
Expand Down Expand Up @@ -771,8 +808,7 @@ function ReadOnlyContextBanner({
}: ReadOnlyContextBannerProps) {
const isParentThreadExpanded =
expandedSection === "parentThread" && parentThreadSection !== null;
const hasMultipleSegments = parentThreadSection !== null;
const showStatusAction = statusAction !== null && !hasMultipleSegments;
const showStatusAction = statusAction !== null;
return (
<PromptStackCard
ariaLabel="Thread context before sending"
Expand Down Expand Up @@ -815,10 +851,7 @@ function ReadOnlyContextBanner({
className="size-3.5 shrink-0"
aria-hidden="true"
/>
<span
className="min-w-0 truncate"
aria-hidden="true"
>
<span className="min-w-0 truncate" aria-hidden="true">
{statusLabel}
</span>
</div>
Expand Down Expand Up @@ -863,21 +896,33 @@ export function ThreadPromptContextBanner({
onToggleSection,
}: ThreadPromptContextBannerProps) {
if (archivedSection || environmentGoneSection) {
const environmentGone = environmentGoneSection !== null;
const environmentGoneCopy = environmentGoneSection
? ENVIRONMENT_GONE_STATUS_COPY[environmentGoneSection.status]
: null;
const environmentGoneAriaLabel = environmentGoneCopy
? `${environmentGoneCopy.ariaLabel}${
environmentGoneSection?.onHandoff
? ` ${environmentGoneCopy.handoffInstruction}`
: ""
}`
: null;
return (
<ReadOnlyContextBanner
iconName={archivedSection ? "Archive" : "CircleX"}
iconName={environmentGone ? "CircleX" : "Archive"}
statusAriaLabel={
archivedSection
? ARCHIVED_THREAD_STATUS_LABEL
: ENVIRONMENT_GONE_ARIA_LABEL
environmentGoneAriaLabel ?? ARCHIVED_THREAD_STATUS_LABEL
}
statusLabel={
archivedSection
? ARCHIVED_THREAD_STATUS_LABEL
: ENVIRONMENT_GONE_STATUS_LABEL
environmentGoneCopy?.label ?? ARCHIVED_THREAD_STATUS_LABEL
}
statusAction={
archivedSection?.onUnarchive ? (
environmentGoneSection?.onHandoff ? (
<EnvironmentHandoffTextAction
status={environmentGoneSection.status}
onHandoff={environmentGoneSection.onHandoff}
/>
) : archivedSection?.onUnarchive && !environmentGone ? (
<ThreadUnarchiveTextAction
isPending={archivedSection.unarchivePending}
onUnarchive={archivedSection.onUnarchive}
Expand Down Expand Up @@ -974,8 +1019,7 @@ export function ThreadPromptContextBanner({
// inline as "Parent <name>" with the name as a link. There's no other
// context to compete for the row, so the icon-only toggle would be a strict
// downgrade in legibility.
const isParentThreadOnly =
showParentThread && !showGit && !showPullRequest;
const isParentThreadOnly = showParentThread && !showGit && !showPullRequest;

const pullRequest = pullRequestSection?.pullRequest ?? null;
const showPullRequestLabel =
Expand Down
Loading
Loading