Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,9 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
/>
)}
{isAtCutoff && <EditCutoffBarrier />}
{interruptedBarrierMessageIds.has(message.id) && <InterruptedBarrier />}
{interruptedBarrierMessageIds.has(message.id) && (
<InterruptedBarrier workspaceId={workspaceId} />
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
119 changes: 119 additions & 0 deletions src/browser/features/Messages/ChatBarrier/InterruptedBarrier.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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 interrupted label resumes the stream", async () => {
const view = render(<InterruptedBarrier workspaceId="ws-1" />);

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

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

// Temporarily enables auto-retry (persist:false) before resuming, mirroring
// the RetryBarrier / backend auto-retry flow.
expect(setAutoRetryEnabled).toHaveBeenCalledWith({
workspaceId: "ws-1",
enabled: true,
persist: false,
});
expect(resumeStream.mock.calls[0]?.[0]).toMatchObject({ workspaceId: "ws-1" });
});
});
24 changes: 22 additions & 2 deletions src/browser/features/Messages/ChatBarrier/InterruptedBarrier.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import React from "react";
import { BaseBarrier } from "./BaseBarrier";
import { useResumeStream } from "@/browser/hooks/useResumeStream";

interface InterruptedBarrierProps {
workspaceId: string;
className?: string;
}

export const InterruptedBarrier: React.FC<InterruptedBarrierProps> = ({ className }) => {
return <BaseBarrier text="interrupted" color="var(--color-interrupted)" className={className} />;
/**
* Decorative "interrupted" divider shown on a partial assistant turn. Clicking
* the label continues the stream from where it stopped, identical to the
* RetryBarrier's Retry button and the backend auto-retry path (resumeStream).
* This is the only continue affordance for user-initiated (Esc) interrupts,
* where the RetryBarrier is intentionally suppressed.
*/
export const InterruptedBarrier: React.FC<InterruptedBarrierProps> = (props) => {
// resume() internally guards against re-entrancy while a resume is in flight,
// so we always keep the clickable label mounted (no button<->div remount flicker).
const { resume } = useResumeStream(props.workspaceId);
Comment thread
ethanndickson marked this conversation as resolved.
Outdated
return (
<BaseBarrier
text="interrupted"
color="var(--color-interrupted)"
className={props.className}
onClick={() => void resume()}
ariaLabel="Continue interrupted response"
/>
);
};
Loading
Loading