Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
10 changes: 9 additions & 1 deletion src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,15 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
/>
)}
{isAtCutoff && <EditCutoffBarrier />}
{interruptedBarrierMessageIds.has(message.id) && <InterruptedBarrier />}
{interruptedBarrierMessageIds.has(message.id) && (
// Only the divider on the resume target (history tail) is clickable;
// resumeStream always continues the tail, so older partial dividers
// must stay decorative to avoid resuming the wrong turn.
<InterruptedBarrier
workspaceId={workspaceId}
resumable={message.id === lastRetryCandidateMessage?.id}
Comment thread
ethanndickson marked this conversation as resolved.
Outdated
/>
)}
</React.Fragment>
);
};
Expand Down
36 changes: 30 additions & 6 deletions src/browser/features/Messages/ChatBarrier/BaseBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ interface BaseBarrierProps {
color: string;
animate?: boolean;
className?: string;
/**
* When provided, the centered label becomes a clickable affordance (the
* gradient lines stay inert). The label gains a hover underline and pointer
* cursor; nothing else about the barrier changes visually.
*/
onClick?: () => void;
/** Accessible label for the clickable variant (defaults to `text`). */
ariaLabel?: string;
}

const LABEL_CLASS = "font-mono text-[10px] tracking-wide whitespace-nowrap uppercase";

export const BaseBarrier: React.FC<BaseBarrierProps> = ({
text,
color,
animate = false,
className,
onClick,
ariaLabel,
}) => {
return (
<div
Expand All @@ -28,12 +40,24 @@ export const BaseBarrier: React.FC<BaseBarrierProps> = ({
background: `linear-gradient(to right, transparent, ${color} 20%, ${color} 80%, transparent)`,
}}
/>
<div
className="font-mono text-[10px] tracking-wide whitespace-nowrap uppercase"
style={{ color }}
>
{text}
</div>
{onClick ? (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel ?? text}
className={cn(
LABEL_CLASS,
"m-0 cursor-pointer border-none bg-transparent p-0 leading-none hover:underline"
)}
style={{ color }}
>
{text}
</button>
) : (
<div className={LABEL_CLASS} style={{ color }}>
{text}
</div>
)}
<div
className="h-px flex-1 opacity-30"
style={{
Expand Down
123 changes: 123 additions & 0 deletions src/browser/features/Messages/ChatBarrier/InterruptedBarrier.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";

import type * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore";

interface MockWorkspaceState {
autoRetryStatus: null;
isStreamStarting: boolean;
canInterrupt: boolean;
messages: Array<Record<string, unknown>>;
}

function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): MockWorkspaceState {
return {
autoRetryStatus: null,
isStreamStarting: false,
canInterrupt: false,
messages: [
{
type: "user",
id: "user-1",
historyId: "user-1",
content: "Hello",
historySequence: 1,
},
],
...overrides,
};
}

let currentWorkspaceState = createWorkspaceState();

let resumeStreamResult: { success: true; data: { started: boolean } } = {
success: true,
data: { started: true },
};
const resumeStream = mock((_input: unknown) => Promise.resolve(resumeStreamResult));
const setAutoRetryEnabled = mock((input: unknown) =>
Promise.resolve({
success: true as const,
data: {
previousEnabled: true,
enabled:
typeof input === "object" && input !== null && "enabled" in input
? ((input as { enabled?: boolean }).enabled ?? true)
: true,
},
})
);

void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({
api: {
workspace: {
resumeStream,
setAutoRetryEnabled,
},
},
status: "connected" as const,
error: null,
authenticate: () => undefined,
retry: () => undefined,
}),
}));

/* eslint-disable @typescript-eslint/no-require-imports */
const actualWorkspaceStore =
require("@/browser/stores/WorkspaceStore?real=1") as typeof WorkspaceStoreModule;
/* eslint-enable @typescript-eslint/no-require-imports */

void mock.module("@/browser/stores/WorkspaceStore", () => ({
...actualWorkspaceStore,
useWorkspaceState: () => currentWorkspaceState,
useWorkspaceStoreRaw: () => ({
getWorkspaceState: (_workspaceId: string) => currentWorkspaceState,
}),
}));

import { InterruptedBarrier } from "./InterruptedBarrier";

describe("InterruptedBarrier", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;

currentWorkspaceState = createWorkspaceState();
resumeStreamResult = { success: true, data: { started: true } };
resumeStream.mockClear();
setAutoRetryEnabled.mockClear();
});

afterEach(() => {
cleanup();
mock.restore();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
});

test("clicking the resumable interrupted label resumes the tail without touching auto-retry", async () => {
const view = render(<InterruptedBarrier workspaceId="ws-1" resumable />);

const label = view.getByRole("button", { name: "Continue interrupted response" });
fireEvent.click(label);

await waitFor(() => {
expect(resumeStream).toHaveBeenCalledTimes(1);
});

// A user-initiated (Esc) interrupt means "continue once" — it must NOT flip
// the auto-retry preference, so the unmount-on-auto-retry path can't cancel
// an in-flight scheduled retry.
expect(setAutoRetryEnabled).not.toHaveBeenCalled();
expect(resumeStream.mock.calls[0]?.[0]).toMatchObject({ workspaceId: "ws-1" });
});

test("a non-resumable divider renders no clickable control", () => {
const view = render(<InterruptedBarrier workspaceId="ws-1" resumable={false} />);

expect(view.queryByRole("button")).toBeNull();
expect(view.getByText("interrupted")).toBeTruthy();
});
});
32 changes: 30 additions & 2 deletions src/browser/features/Messages/ChatBarrier/InterruptedBarrier.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import React from "react";
import { BaseBarrier } from "./BaseBarrier";
import { useResumeStream } from "@/browser/hooks/useResumeStream";

interface InterruptedBarrierProps {
workspaceId: string;
/**
* Whether this divider sits on the current resume target (the history tail).
* resumeStream always continues the tail, so only the tail divider is made
* clickable — older partial dividers stay decorative to avoid resuming the
* wrong turn.
*/
resumable?: boolean;
className?: string;
}

export const InterruptedBarrier: React.FC<InterruptedBarrierProps> = ({ className }) => {
return <BaseBarrier text="interrupted" color="var(--color-interrupted)" className={className} />;
/**
* "interrupted" divider shown on a partial assistant turn. When it sits on the
* resumable tail, clicking the label continues the stream from where it stopped
* (same backend path as RetryBarrier / auto-retry). It is the only continue
* affordance for user-initiated (Esc) interrupts, where RetryBarrier is
* suppressed. autoRetryOnFailure is disabled because ChatPane unmounts this
* divider once auto-retry becomes active; see useResumeStream for why that
* matters.
*/
export const InterruptedBarrier: React.FC<InterruptedBarrierProps> = (props) => {
// resume() internally guards against re-entrancy while a resume is in flight.
const { resume } = useResumeStream(props.workspaceId, { autoRetryOnFailure: false });
Comment thread
ethanndickson marked this conversation as resolved.
Outdated
return (
<BaseBarrier
text="interrupted"
color="var(--color-interrupted)"
className={props.className}
onClick={props.resumable ? () => void resume() : undefined}
ariaLabel={props.resumable ? "Continue interrupted response" : undefined}
/>
);
};
Loading
Loading