Skip to content
Merged
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
199 changes: 189 additions & 10 deletions apps/web/e2e/agents-lifecycle.spec.ts

Large diffs are not rendered by default.

37 changes: 32 additions & 5 deletions apps/web/e2e/fixture-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,25 @@ function emitTurnLifecycle(status) {
return true;
}

function emitSessionLifecycle(status) {
if (!["in_progress", "idle"].includes(status)) return false;
const session = state.sessions.find((candidate) => candidate.id === "session_snapshot");
if (!session) return false;
session.status = status;
session.required_actions = [];
state.sequence += 1;
const event = `id: session_${state.sequence}\ndata: ${JSON.stringify({
type: `agent.session.${status}`,
event_id: `session_${state.sequence}`,
session_id: session.id,
session,
})}\n\n`;
for (const [stream, sessionId] of streamResponses) {
if (sessionId === session.id) stream.write(event);
}
return true;
}

function sendJson(response, value, status = 200) {
const body = JSON.stringify(value);
response.writeHead(status, {
Expand Down Expand Up @@ -409,6 +428,12 @@ const server = http.createServer(async (request, response) => {
? sendJson(response, { emitted: true })
: sendError(response, 400, "Fixture terminal Turn is unavailable.");
}
if (request.method === "POST" && url.pathname === "/__fixture/emit-session") {
const input = await readJson(request);
return emitSessionLifecycle(input.status)
? sendJson(response, { emitted: true })
: sendError(response, 400, "Fixture Session lifecycle status is unavailable.");
}
if (request.method === "GET" && url.pathname === "/__fixture/requests") {
return sendJson(response, state.requests);
}
Expand Down Expand Up @@ -659,11 +684,13 @@ const server = http.createServer(async (request, response) => {
}
const items = sessionId !== "session_snapshot"
? []
: state.controls.itemsScenario
? patchItems()
: state.controls.turnsScenario
? observableTurnItems()
: [];
: state.controls.itemsScenario === 2
? [...observableTurnItems(), ...patchItems()]
: state.controls.itemsScenario
? patchItems()
: state.controls.turnsScenario
? observableTurnItems()
: [];
return sendJson(response, page(items));
}

Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/features/CoreCollectionStates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,33 @@ describe("Agent Core collection states", () => {
expect(failed).toContain("Researcher");
});

it("shows composer activity only for a Core-reported in-progress Session", () => {
const renderStatus = (status: AgentSession["status"], busy = false) => renderToStaticMarkup(
<SessionsView
agents={[]}
sessions={[selectedSession]}
selected={{ ...selectedSession, status }}
items={durableItems}
busy={busy}
coreError={null}
coreState="ready"
detailError={null}
detailState="ready"
streamError={null}
streamState="listening"
{...sessionsCallbacks}
/>,
);

const active = renderStatus("in_progress");
expect(active).toContain('class="conversation-activity"');
expect(active).toContain('role="status" aria-live="polite" aria-atomic="true"');
expect(active).toContain("Researcher is working…");
expect(renderStatus("idle", true)).not.toContain("conversation-activity");
expect(renderStatus("requires_action")).not.toContain("conversation-activity");
expect(renderStatus("failed")).not.toContain("conversation-activity");
});

it("keeps Sessions loading and failure distinct from ready empty copy", () => {
const common = {
agents: [],
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/agents/AgentsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ describe("Agents view", () => {

expect(html).toContain('type="button" aria-label="Open details for this Agent"');
expect(html).toContain('aria-disabled="true" aria-label="Start a Session with this Agent"');
expect(html).toContain('<span class="ledger-session-header" role="columnheader">Session</span>');
expect(html).toContain('<span>Unavailable</span>');
expect(html).toContain("Session unavailable: Current Core Session admission requires");
});

Expand All @@ -91,6 +93,7 @@ describe("Agents view", () => {
);

expect(html).toContain('type="button" aria-label="Start a Session with this Agent"');
expect(html).toContain('<span>Start Session</span>');
expect(html).not.toContain("Session unavailable:");
});

Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/features/agents/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,24 @@ function AgentSessionStartAction({
return (
<span className="action-tooltip">
<button
className="icon-button ghost agent-session-start"
className="button outline agent-session-start"
type="button"
onClick={() => {
if (!blocker) onStart(agent.id);
}}
disabled={busy}
aria-disabled={blocker ? true : undefined}
aria-label={`Start a Session with ${agent.name || "this Agent"}`}
aria-describedby={descriptionId}
aria-describedby={blocker ? descriptionId : undefined}
>
<MessageSquare size={14} strokeWidth={1.5} />
<MessageSquare size={14} strokeWidth={1.5} aria-hidden="true" />
<span>{blocker ? "Unavailable" : "Start Session"}</span>
</button>
<span className="action-tooltip-content" role="tooltip" id={descriptionId}>
{blocker ? `Session unavailable: ${blocker}` : "Start Session"}
</span>
{blocker ? (
<span className="action-tooltip-content" role="tooltip" id={descriptionId}>
Session unavailable: {blocker}
</span>
) : null}
</span>
);
}
Expand Down Expand Up @@ -372,7 +375,7 @@ export function AgentsView({
<span role="columnheader">Model</span>
<span role="columnheader">Tools</span>
<span role="columnheader">Updated</span>
<span role="columnheader" aria-label="Actions" />
<span className="ledger-session-header" role="columnheader">Session</span>
</div>
<div className="ledger-body" role="rowgroup">
{filteredAgents.map((agent) => (
Expand Down
99 changes: 90 additions & 9 deletions apps/web/src/features/sessions/SessionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ import {
} from "./environment/EnvironmentPanel";
import type { EnvironmentObservation } from "./environment/environment-state";
import { ThreadItems } from "./items/ItemRenderers";
import { TurnTimeline, type TurnTimelineLoadState } from "./turns/TurnTimeline";
import { TraceView } from "./trace/TraceView";
import type { TurnTimelineLoadState } from "./turns/TurnTimeline";
import { SessionActionsDialog } from "./actions/SessionActionsDialog";

export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed";
export type SessionDetailState = "idle" | "loading" | "ready" | "failed";
type SessionView = "conversation" | "trace";

interface SessionsViewProps {
agents: SavedAgent[];
Expand Down Expand Up @@ -216,6 +218,15 @@ function CancelOnlyBar({ busy, onCancel }: { busy: boolean; onCancel: () => void
);
}

function ConversationActivity({ agentName }: { agentName: string }) {
return (
<div className="conversation-activity" role="status" aria-live="polite" aria-atomic="true">
<StatusIcon status="running" />
<span>{agentName} is working…</span>
</div>
);
}

function FunctionActionBar({
actions,
agentName,
Expand Down Expand Up @@ -329,6 +340,7 @@ export function SessionsView({
: "Create or load a saved Agent before starting a Session."
: null;
const [message, setMessage] = useState("");
const [sessionView, setSessionView] = useState<SessionView>("conversation");
const [newSessionOpen, setNewSessionOpen] = useState(false);
const [agentId, setAgentId] = useState(firstStartableAgent?.id ?? "");
const [actionSession, setActionSession] = useState<AgentSession | null>(null);
Expand Down Expand Up @@ -461,6 +473,25 @@ export function SessionsView({
(unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0),
);

const onViewTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const tabs = Array.from(event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>("[role=tab]") ?? []);
const index = tabs.indexOf(event.currentTarget);
if (index < 0) return;
const target = event.key === "ArrowRight"
? tabs[(index + 1) % tabs.length]
: event.key === "ArrowLeft"
? tabs[(index - 1 + tabs.length) % tabs.length]
: event.key === "Home"
? tabs[0]
: event.key === "End"
? tabs[tabs.length - 1]
: null;
if (!target) return;
event.preventDefault();
target.click();
target.focus();
};

return (
<section ref={pageRef} className="page-section session-page" tabIndex={-1}>
<aside className="session-browser">
Expand Down Expand Up @@ -589,6 +620,40 @@ export function SessionsView({
</button>
</header>

<div className="session-view-tabs" role="tablist" aria-label="Session view">
<button
id="session-conversation-tab"
type="button"
role="tab"
aria-controls="session-conversation-panel"
aria-selected={sessionView === "conversation"}
tabIndex={sessionView === "conversation" ? 0 : -1}
onClick={() => setSessionView("conversation")}
onKeyDown={onViewTabKeyDown}
>
Conversation
</button>
<button
id="session-trace-tab"
type="button"
role="tab"
aria-controls="session-trace-panel"
aria-selected={sessionView === "trace"}
tabIndex={sessionView === "trace" ? 0 : -1}
onClick={() => setSessionView("trace")}
onKeyDown={onViewTabKeyDown}
>
Trace
</button>
</div>

<div
className="session-view-panel conversation-view-panel"
id="session-conversation-panel"
role="tabpanel"
aria-labelledby="session-conversation-tab"
hidden={sessionView !== "conversation"}
>
<div className="conversation-thread-frame">
<div ref={setViewport} className="conversation-scroll">
<div ref={setThreadContent} className="thread-content">
Expand All @@ -604,14 +669,6 @@ export function SessionsView({
connectionActions={environmentConnections}
/>

<TurnTimeline
turns={turns}
items={items}
sessionUsage={selected.usage}
loadState={turnState}
error={turnError}
/>

<div className="message-stack">
<ThreadItems items={items} agentName={selected.agent.name || "Agent"} />
</div>
Expand Down Expand Up @@ -699,6 +756,9 @@ export function SessionsView({
<EnvironmentConnectionNotice action={action} key={`${action.environment_id}:${index}`} />
))}
{unsupportedActionCount ? <UnsupportedActionNotice /> : null}
{selected.status === "in_progress" ? (
<ConversationActivity agentName={selected.agent.name || "Agent"} />
) : null}
{showCancelOnly ? (
<CancelOnlyBar busy={busy} onCancel={cancel} />
) : null}
Expand Down Expand Up @@ -751,6 +811,27 @@ export function SessionsView({
</form>
)}
</footer>
</div>
{sessionView === "trace" ? (
<TraceView
id="session-trace-panel"
labelledBy="session-trace-tab"
session={selected}
turns={turns}
items={items}
detailState={detailState}
detailError={detailError}
turnState={turnState}
turnError={turnError}
/>
) : (
<div
id="session-trace-panel"
role="tabpanel"
aria-labelledby="session-trace-tab"
hidden
/>
)}
</div>
) : (
<div className="workspace-empty">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,9 @@ function render(environment: AgentEnvironment, live: EnvironmentObservation | nu
}

describe("EnvironmentPanel", () => {
it("renders environment:none as Core-owned with no Workspace", () => {
it("does not render Environment or Workspace UI for environment:none", () => {
const html = render({ type: "none" });
expect(html).toContain("Core-owned");
expect(html).toContain("No Workspace");
expect(html).not.toContain("file://");
expect(html).toBe("");
});

it("renders a canonical durable UUID for an uppercase Session Environment identity", () => {
Expand Down
12 changes: 2 additions & 10 deletions apps/web/src/features/sessions/environment/EnvironmentPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ExternalLink, Folder, HardDrive, Server, TerminalSquare } from "lucide-react";
import { ExternalLink, Folder, HardDrive, TerminalSquare } from "lucide-react";

import type {
AgentEnvironment,
Expand Down Expand Up @@ -100,15 +100,7 @@ export function EnvironmentPanel({
const type = typeof raw.type === "string" ? raw.type : null;

if (type === "none") {
return (
<section className="environment-panel environment-panel-none" aria-label="Environment and Workspace status">
<div className="environment-panel-heading">
<Server size={15} strokeWidth={1.5} aria-hidden="true" />
<div><strong>Core-owned</strong><span>No Workspace</span></div>
</div>
<p>Core owns runtime placement. This Session has no Environment execution directory.</p>
</section>
);
return null;
}

if (type !== "self_hosted") {
Expand Down
Loading
Loading