Skip to content
16 changes: 16 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ export function isFailedStep(status: TurnStepStatus | undefined): boolean {
return status === "error";
}

/** One addressable object produced by a chat-started agent turn. */
export interface ChatOutput {
kind: "workspace-node" | "artifact";
targetId: string;
/** Bounded, redacted label supplied by the host. */
title: string;
/** Required for artifact links; absent for workspace nodes. */
taskId?: string;
/** Exact artifact revision produced by the turn. */
version?: number;
}

/** One channel reply from a cycle. */
export interface OutboundMessage {
channel: string;
Expand All @@ -172,6 +184,8 @@ export interface OutboundMessage {
* wrong. The bubble's `steps` timeline still shows every spawn.
*/
taskId?: string;
/** Workspace nodes and artifacts produced by this reply's turn. */
outputs?: ChatOutput[];
/**
* Who this reply names, as the host resolved them (issue #1645).
*
Expand Down Expand Up @@ -461,6 +475,8 @@ export interface ChatHistoryMessageDto {
* renders identically whichever surface hydrated the transcript.
*/
taskId?: string;
/** Live output buttons to restore when this transcript is rehydrated. */
outputs?: ChatOutput[];
/**
* The message this one replies to (issue #364), by that message's own `id`.
* Absent for a message posted straight into the channel — which is every
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2594,6 +2594,7 @@ export function AppShell({
makeMessage(from, event.text, {
channel: event.agentId,
taskId: event.taskId,
outputs: event.outputs,
mentions: event.mentions,
// Issue #483: same identity as the thread store above. This is
// the store `hydrateThread` folds into, so this is where the
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/hooks/use-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type CompanyStreamEvent =
* chat reply.
*/
taskId?: string;
outputs?: import("@/api/types").ChatOutput[];
/**
* The message this reply belongs under (issue #364) — its thread inside
* the channel. Absent for a reply in the channel itself, and on a host
Expand Down Expand Up @@ -709,6 +710,8 @@ export interface AgentReplyEvent {
seq: number;
/** The board card this reply opened (issue #246), when it opened one. */
taskId?: string;
/** Workspace nodes and artifacts produced by this reply's turn. */
outputs?: import("@/api/types").ChatOutput[];
/**
* The **host-side** id of the message this reply belongs under (issue #364).
* Namespaced into a console id by the injector, which is what knows about the
Expand Down Expand Up @@ -1261,6 +1264,7 @@ export function handleEvent(
// not POST for, e.g. an inbound channel turn — carries its "card
// opened" chip too, rather than only the locally-awaited copy.
taskId: event.taskId,
outputs: event.outputs,
// Issue #364: and lands in the same thread a reload would put it in,
// rather than arriving in the channel and jumping on the next refresh.
parentId: event.parentId,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AttachmentDto,
ChatHistoryMessageDto,
ChatMentionDto,
ChatOutput,
TurnStep,
} from "@/api/types";

Expand Down Expand Up @@ -248,6 +249,8 @@ export interface ChatMessage {
* chip linking to `#/tasks/<id>`.
*/
taskId?: string;
/** Workspace nodes and artifacts produced by this reply's turn. */
outputs?: ChatOutput[];
/**
* Files attached to this line (issue #1682), each a reference into the
* company workspace. Set on your own message from the composer's pending
Expand Down Expand Up @@ -439,6 +442,7 @@ export function makeMessage(
parentId?: string;
steps?: TurnStep[];
taskId?: string;
outputs?: ChatOutput[];
messageId?: string;
attachments?: AttachmentDto[];
/** Mention spans the host resolved against this message, for chip rendering. */
Expand All @@ -454,6 +458,7 @@ export function makeMessage(
parentId: opts.parentId,
steps: opts.steps,
taskId: opts.taskId,
outputs: opts.outputs?.length ? opts.outputs : undefined,
// Issue #1682: an empty list is dropped to `undefined` so a line with no
// attachment stays exactly the shape it was before the field existed.
attachments: opts.attachments?.length ? opts.attachments : undefined,
Expand Down Expand Up @@ -606,6 +611,10 @@ export function fromHistory(entries: ChatHistoryMessageDto[]): ChatMessage[] {
// Only your own lines never have one — you did not open a card by
// speaking.
taskId: from === "you" ? undefined : entry.taskId,
// Keep the produced-file buttons on exactly the same durable path as the
// card chip above. A live-only field vanishes on the first thread switch
// or page reload and makes a valid output look broken.
outputs: from === "you" || !entry.outputs?.length ? undefined : entry.outputs,
// Rehydrate the operator's attachments (issue #1682) so a bubble carries
// the same chips on reload it showed live. Empty drops to `undefined`,
// keeping the pre-#1682 line shape.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/views/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2270,6 +2270,7 @@ export function RoomView({
parentId,
steps: r.steps,
taskId: r.taskId,
outputs: r.outputs,
messageId: r.messageId,
mentions: r.mentions,
}),
Expand Down
70 changes: 69 additions & 1 deletion frontend/src/views/room/MessageRow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MessageSquareReply, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { FileText, MessageSquareReply, Paperclip, TriangleAlert } from "lucide-react";

import type { TaskStatus } from "@/api/tasks";
import type { CognitionState, TurnStep } from "@/api/types";
Expand All @@ -9,6 +10,7 @@ import type { EpisodeTurn } from "@/lib/hive/episode";
import { TeammateAvatar } from "@/components/teammate-avatar";
import { Button } from "@/components/ui/button";
import { consoleHref } from "@/lib/console-paths";
import { artifactHref } from "@/lib/task-output";
import { IN_FLIGHT_COLUMNS } from "@/lib/board-columns";
import { isHostMessageId, type ChatMessage } from "@/lib/chat";
import { isBudgetPauseNotice } from "@/hooks/use-events";
Expand Down Expand Up @@ -455,6 +457,9 @@ export function MessageRow({
)}

{message.steps && message.steps.length > 0 && <StepTimeline steps={message.steps} />}
{message.outputs && message.outputs.length > 0 && (
<OutputLinkRow outputs={message.outputs} />
)}
{/* The running turn this message asked for. Opens by default: unlike a
settled turn's steps — which sit behind a count because the answer
above them is what the reader came for — there is no answer yet, and
Expand Down Expand Up @@ -683,6 +688,69 @@ function SystemPill({
);
}

/** The reply-level buttons for objects this turn produced. */
export function OutputLinkRow({ outputs }: { outputs: NonNullable<ChatMessage["outputs"]> }) {
const [expanded, setExpanded] = useState(false);
const links: {
key: string;
href: string;
label: string;
kind: "workspace-node" | "artifact";
}[] = [];
for (const output of outputs) {
if (output.kind === "workspace-node") {
links.push({
key: `${output.kind}:${output.targetId}`,
href: consoleHref("workspace", output.targetId),
label: output.title,
kind: output.kind,
});
continue;
}
if (output.taskId !== undefined && output.version !== undefined) {
links.push({
key: `${output.kind}:${output.targetId}:${output.version}`,
href: artifactHref(output.taskId, output.targetId, output.version),
label: output.title,
kind: output.kind,
});
}
}
if (links.length === 0) return null;

const visible = expanded ? links : links.slice(0, 1);
return (
<div className="mt-1.5 flex flex-wrap items-center gap-2" data-chat-output-links>
{visible.map((link) => (
<a
key={link.key}
href={link.href}
title={`Open ${link.label}`}
className="flex h-7 max-w-full min-w-0 items-center gap-1.5 rounded-full bg-muted px-3 text-xs text-muted-foreground transition-opacity hover:opacity-80"
>
{link.kind === "artifact" ? (
<Paperclip className="size-3.5 shrink-0" />
) : (
<FileText className="size-3.5 shrink-0" />
)}
<span className="truncate">{link.label}</span>
</a>
))}
{links.length > 1 && (
<Button
size="sm"
variant="outline"
className="h-7 rounded-full px-2 text-xs"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
>
{expanded ? "Show less" : `+${links.length - 1} more`}
</Button>
)}
</div>
);
}

function AuthorLine({
sender,
at,
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/views/room/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { TeamMember } from "@/lib/team";
import { cn } from "@/lib/utils";
import { BudgetPauseNoticeCard } from "./BudgetPauseNoticeCard";
import { EchoPlaceholder, echoMarkerFor } from "./EchoPlaceholder";
import { FailedSendNotice } from "./MessageRow";
import { FailedSendNotice, OutputLinkRow } from "./MessageRow";
import { MessageAttachments } from "./MessageAttachments";
import { ReferralChip, ReferralConversation, StepTimeline } from "./StepTimeline";
import { MessageComposer } from "./MessageComposer";
Expand Down Expand Up @@ -516,6 +516,9 @@ function Line({
of itself anywhere, even after the panel was closed (Codex on
#2069). */}
{message.steps && message.steps.length > 0 && <StepTimeline steps={message.steps} />}
{message.outputs && message.outputs.length > 0 && (
<OutputLinkRow outputs={message.outputs} />
)}
{!!liveSteps?.length && <StepTimeline steps={[...liveSteps]} defaultOpen />}
{/* And the crossings, for the same reason the steps are here: a room's
turns are threaded, so this panel is the only surface a deliberating
Expand Down
149 changes: 149 additions & 0 deletions frontend/test/unit/chat-output-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// @vitest-environment jsdom

import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { ChatHistoryMessageDto, ChatOutput } from "@/api/types";
import { fromHistory } from "@/lib/chat";
import type { TeamMember } from "@/lib/team";
import { OutputLinkRow } from "@/views/room/MessageRow";
import { ThreadPanel } from "@/views/room/ThreadPanel";
import type { Channel } from "@/views/room/model";

const CHANNEL: Channel = {
id: "general",
name: "general",
voice: "General",
kind: "channel",
purpose: "",
};

const MEMBERS: TeamMember[] = [
{
id: "writer",
name: "Writer",
role: "Writer",
description: "",
tone: "violet",
avatar: "badger",
inboxEnabled: true,
effectiveTools: [],
desks: [],
},
];

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

function rehydratedOutputs(outputs: ChatOutput[]): ChatOutput[] {
const entry: ChatHistoryMessageDto = {
id: "42",
channel: "writer",
author: "writer",
text: "Done.",
atMillis: 1_700_000_000_000,
mine: false,
outputs,
};
return fromHistory([entry])[0]?.outputs ?? [];
}

function render(outputs: ChatOutput[]) {
act(() => root.render(createElement(OutputLinkRow, { outputs: rehydratedOutputs(outputs) })));
}

describe("chat reply output links", () => {
it("rehydrates one workspace output as a button link", () => {
render([
{
kind: "workspace-node",
targetId: "node-1",
title: "launch-note.md",
},
]);

const link = container.querySelector("a");
expect(link?.textContent).toContain("launch-note.md");
expect(link?.getAttribute("href")).toBe("#/company/workspace/node-1");
expect(container.querySelector("button")).toBeNull();
});

it("collapses several outputs behind a +N more control", () => {
render([
{ kind: "workspace-node", targetId: "node-1", title: "first.md" },
{
kind: "artifact",
targetId: "artifact-2",
title: "Second draft",
taskId: "task-7",
version: 3,
},
{ kind: "workspace-node", targetId: "node-3", title: "third.md" },
]);

expect(container.querySelectorAll("a")).toHaveLength(1);
const more = container.querySelector("button");
expect(more?.textContent).toBe("+2 more");
act(() => more?.click());
expect(container.querySelectorAll("a")).toHaveLength(3);
expect(container.querySelectorAll("a")[1]?.getAttribute("href")).toBe(
"#/tasks/task-7?artifact=artifact-2&v=3",
);
});

it("renders no row when the reply produced nothing", () => {
render([]);
expect(container.querySelector("[data-chat-output-links]")).toBeNull();
});

it("renders a rehydrated output on a reply that lives only in a thread", () => {
const outputs = rehydratedOutputs([
{
kind: "workspace-node",
targetId: "thread-node",
title: "thread-note.md",
},
]);

act(() =>
root.render(
createElement(ThreadPanel, {
channel: CHANNEL,
members: MEMBERS,
parent: { id: "parent", from: "you", text: "Write the note", at: 1 },
replies: [
{
id: "reply",
parentId: "parent",
from: "company",
channel: "writer",
text: "Done.",
at: 2,
outputs,
},
],
sending: false,
onSend: vi.fn(),
onClose: vi.fn(),
}),
),
);

const link = container.querySelector('[data-chat-output-links] a');
expect(link?.textContent).toContain("thread-note.md");
expect(link?.getAttribute("href")).toBe("#/company/workspace/thread-node");
});
});
Loading
Loading