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
12 changes: 12 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,18 @@ _Avoid_: default to Discover
A backend-owned external capability exposed through MCP. Tools are managed separately from Plugins even though both can extend agent behaviour.
_Avoid_: Plugin, frontend tool

**Tool call**:
A single visible use of a Tool within a Session transcript or Live Session stream. A Tool call has a tool name, input, output, and status (`running`, `success`, or `error`); protocol states such as pending are implementation details and present as running. Tool calls are passive execution metadata attached to an assistant turn, not chat-equivalent assistant content and not user-actionable prompts.
_Avoid_: plugin call, raw log line, assistant message, permission request, question prompt

**Tool call presentation**:
The compact UI rendering of a Tool call. Collapsed Tool call rows show a status icon plus a semantic label for known tools, or a prettified tool name for unknown/custom MCP tools. Inputs are hidden for now. Outputs, including error text, terminal text, structured output, and images, are shown only when the Tool call is expanded. A Tool call is expandable only when it has meaningful output; empty, whitespace-only, or structurally useless output must not create an expansion affordance or blank body. Long expanded output is bounded and scrollable.
_Avoid_: always show input, JSON inspector by default, empty expandable row, inline error by default

**Interaction request**:
A user-actionable, agent-blocking request inside a Session where the agent is paused until the user decides something. Permission requests and question prompts are Interaction requests, not ordinary Tool calls. They should render as prominent actionable panels rather than passive collapsible Tool call rows.
_Avoid_: tool output, permission tool call, question tool call, passive transcript metadata

**Harness restart**:
A user-requested recovery action that hard-restarts all Harness processes managed by OpenGUI, not just the currently selected Harness. It stops background agent processes such as the Pi daemon before starting them again.
_Avoid_: server restart, selected backend restart, soft reconnect
Expand Down
22 changes: 22 additions & 0 deletions src/agents/protocol/opencode-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ describe("mapOpenCodeEvent", () => {
});
});

test("ignores malformed session lifecycle events without crashing SSE handling", () => {
expect(
mapOpenCodeEvent(
{
type: "session.updated",
properties: { info: { title: "Missing ID" } },
} as unknown as OpenCodeEvent,
context,
),
).toBeNull();

expect(
mapOpenCodeEvent(
{
type: "session.created",
properties: {},
} as unknown as OpenCodeEvent,
context,
),
).toBeNull();
});

test("normalizes message IDs", () => {
const message = { id: "message-1", sessionID: "session-1" } as unknown as Message;

Expand Down
11 changes: 11 additions & 0 deletions src/agents/protocol/opencode-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ type OpenCodeEventHandler = (

type EventProperties = Record<string, unknown>;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isTaggedSession(value: unknown): value is TaggedSession {
return isRecord(value) && typeof value.id === "string" && value.id.length > 0;
}

function getProperties(event: OpenCodeEvent): EventProperties {
return event.properties as EventProperties;
}
Expand Down Expand Up @@ -59,6 +67,7 @@ function normalizeOpenCodePayload(raw: OpenCodeEvent | OpenCodeSyncEnvelope): Op
export const openCodeEventHandlers: Partial<Record<string, OpenCodeEventHandler>> = {
"session.created": (event, context) => {
const { info } = getProperties(event) as { info: TaggedSession };
if (!isTaggedSession(info)) return null;
return {
type: "session.created",
directory: context.directory,
Expand All @@ -68,6 +77,7 @@ export const openCodeEventHandlers: Partial<Record<string, OpenCodeEventHandler>
},
"session.updated": (event, context) => {
const { info } = getProperties(event) as { info: TaggedSession };
if (!isTaggedSession(info)) return null;
return {
type: "session.updated",
directory: context.directory,
Expand All @@ -77,6 +87,7 @@ export const openCodeEventHandlers: Partial<Record<string, OpenCodeEventHandler>
},
"session.deleted": (event, context) => {
const { info } = getProperties(event) as { info: { id: string } };
if (!isTaggedSession(info)) return null;
return {
type: "session.deleted",
directory: context.directory,
Expand Down
8 changes: 7 additions & 1 deletion src/agents/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ export function tagBackendSession(
session: TaggedSession,
target?: { directory?: string; workspaceId?: string },
): TaggedSession {
const rawId = session._rawId ?? session.id;
const rawId =
typeof session._rawId === "string"
? session._rawId
: typeof session.id === "string"
? session.id
: null;
if (!rawId) return session;
const projectDir = session.directory ?? session._projectDir ?? target?.directory;
return {
...session,
Expand Down
10 changes: 5 additions & 5 deletions src/components/DialogSelectProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
* Clicking a provider fires onSelect; clicking "Custom" fires onCustom.
*/

import type { Provider } from "@opencode-ai/sdk/v2/client";
import { Check, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ProviderIcon } from "@/components/provider-icons/ProviderIcon";
import { SubDialogHeader } from "@/components/SubDialogHeader";
import { Input } from "@/components/ui/input";
import { POPULAR_PROVIDER_IDS } from "@/lib/constants";
import type { ProviderResource } from "@/protocol/harness-resources";

// ---------------------------------------------------------------------------
// Constants
Expand All @@ -25,7 +25,7 @@ const POPULAR_IDS = new Set<string>(POPULAR_PROVIDER_IDS);
// ---------------------------------------------------------------------------

interface DialogSelectProviderProps {
providers: Provider[];
providers: ProviderResource[];
connectedIds: Set<string>;
onSelect: (providerID: string) => void;
onCustom: () => void;
Expand All @@ -48,8 +48,8 @@ export function DialogSelectProvider({
const lowerSearch = search.toLowerCase().trim();

const { popular, other } = useMemo(() => {
const pop: Provider[] = [];
const oth: Provider[] = [];
const pop: ProviderResource[] = [];
const oth: ProviderResource[] = [];
for (const p of providers) {
// Filter by search
if (
Expand Down Expand Up @@ -159,7 +159,7 @@ function ProviderRow({
connected,
onSelect,
}: {
provider: Provider;
provider: ProviderResource;
connected: boolean;
onSelect: (id: string) => void;
}) {
Expand Down
8 changes: 4 additions & 4 deletions src/components/McpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* No add, edit, or delete - purely runtime toggling.
*/

import type { McpStatus } from "@opencode-ai/sdk/v2/client";
import { AlertCircle, CheckCircle2, Globe, Terminal } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
Expand All @@ -16,6 +15,7 @@ import { Switch } from "@/components/ui/switch";
import { useHarness } from "@/hooks/use-agent-backend";
import { useConnectionState } from "@/hooks/use-agent-state";
import { MCP_TOGGLE_DELAY_MS } from "@/lib/constants";
import type { McpServerStatus } from "@/protocol/harness-resources";

// ---------------------------------------------------------------------------
// Status badge
Expand Down Expand Up @@ -46,7 +46,7 @@ const STATUS_CONFIG = {
},
} as const;

function StatusBadge({ status }: { status: McpStatus }) {
function StatusBadge({ status }: { status: McpServerStatus }) {
const { t } = useTranslation();
const config =
STATUS_CONFIG[status.status as keyof typeof STATUS_CONFIG] ??
Expand Down Expand Up @@ -79,7 +79,7 @@ export function McpDialog({ open, onOpenChange }: McpDialogProps) {
const configApi = backend?.platform?.config;
const { activeDirectory, activeWorkspaceId } = useConnectionState();

const [mcpStatus, setMcpStatus] = useState<Record<string, McpStatus>>({});
const [mcpStatus, setMcpStatus] = useState<Record<string, McpServerStatus>>({});
const [mcpTypes, setMcpTypes] = useState<Record<string, "local" | "remote">>({});
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState<string | null>(null);
Expand Down Expand Up @@ -114,7 +114,7 @@ export function McpDialog({ open, onOpenChange }: McpDialogProps) {
}
}, [open, refresh]);

const handleToggle = async (name: string, currentStatus: McpStatus) => {
const handleToggle = async (name: string, currentStatus: McpServerStatus) => {
if (!mcpApi) return;
setToggling(name);
try {
Expand Down
71 changes: 20 additions & 51 deletions src/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@
* Handles user messages, assistant text, tool calls, and permission requests.
*/

import type { Part, TextPart } from "@opencode-ai/sdk/v2/client";
import { ShieldAlert, Undo2 } from "lucide-react";
import { Undo2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { QuestionPanel } from "@/components/message-list/QuestionPanel";
import { InteractionRequestsView } from "@/components/message-list/interactions/InteractionRequestsView";
import { MessageBubble } from "@/components/message-list/MessageBubble";
import {
type ScrollSnapshot,
VirtualMessageScroller,
} from "@/components/message-list/VirtualMessageScroller";
import type { TurnFooter } from "@/components/message-list/types";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useBackendCapabilities } from "@/hooks/use-agent-backend";
import {
Expand All @@ -24,13 +22,14 @@ import {
useSessionState,
} from "@/hooks/use-agent-state";
import type { TurnRun } from "@/hooks/agent-state-types";
import type { TextTranscriptPart, TranscriptPart } from "@/protocol/session-transcript";
import logoDark from "../../opengui-dark.svg";
import logoLight from "../../opengui-light.svg";

/** Part types that actually render something visible. */
const RENDERABLE_TYPES = new Set(["text", "reasoning", "tool", "file"]);
/** Check if a part will produce visible output. */
function isRenderablePart(part: Part): boolean {
function isRenderablePart(part: TranscriptPart): boolean {
if (!RENDERABLE_TYPES.has(part.type)) return false;
// text parts with empty content render nothing
if (part.type === "text" && !part.text?.trim()) return false;
Expand All @@ -52,9 +51,9 @@ function nonEmptyString(value: unknown): string | undefined {
}

function getEntryText(entry: MessageEntry): string {
return (entry.parts as TextPart[])
return entry.parts
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.map((part) => (part as TextTranscriptPart).text)
.join("\n")
.trim();
}
Expand Down Expand Up @@ -105,7 +104,6 @@ function RevertBanner({
}

export function MessageList({ detachedProject: _detachedProject }: { detachedProject?: string }) {
const { t } = useTranslation();
const {
respondPermission,
replyQuestion,
Expand Down Expand Up @@ -136,7 +134,7 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
const pendingQuestion = activeSessionId ? (pendingQuestions[activeSessionId] ?? null) : null;

const [expandedUserMessages, setExpandedUserMessages] = useState<Set<string>>(() => new Set());
const [expandedToolParts, setExpandedToolParts] = useState<Set<string>>(() => new Set());
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(() => new Set());
const scrollSnapshotsRef = useRef(new Map<string, ScrollSnapshot>());

useEffect(() => {
Expand Down Expand Up @@ -322,8 +320,8 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
});
}, []);

const toggleToolPart = useCallback((partId: string, expanded: boolean) => {
setExpandedToolParts((current) => {
const toggleToolCall = useCallback((partId: string, expanded: boolean) => {
setExpandedToolCalls((current) => {
const next = new Set(current);
if (expanded) next.add(partId);
else next.delete(partId);
Expand Down Expand Up @@ -356,9 +354,9 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
: undefined
}
expandedUserMessages={expandedUserMessages}
expandedToolParts={expandedToolParts}
expandedToolCalls={expandedToolCalls}
onToggleUserMessage={toggleUserMessage}
onToggleToolPart={toggleToolPart}
onToggleToolCall={toggleToolCall}
/>
</div>
);
Expand All @@ -371,9 +369,9 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
forkFromMessage,
revertToMessage,
expandedUserMessages,
expandedToolParts,
expandedToolCalls,
toggleUserMessage,
toggleToolPart,
toggleToolCall,
capabilities,
],
);
Expand Down Expand Up @@ -415,42 +413,13 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
<RevertBanner revertedCount={revertedCount} onRestore={unrevert} />
)}

{capabilities?.permissions && pendingPermission && (
<div className="border rounded-lg p-4 bg-amber-500/10 border-amber-500/30 space-y-3 mt-4">
<div className="flex items-start gap-2">
<ShieldAlert className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t("permissionPanel.title", { permission: pendingPermission.permission })}
</p>
{pendingPermission.patterns.length > 0 && (
<p className="text-xs text-muted-foreground">
{pendingPermission.patterns.join(", ")}
</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="default" onClick={() => respondPermission("once")}>
{t("permissionPanel.allowOnce")}
</Button>
<Button size="sm" variant="secondary" onClick={() => respondPermission("always")}>
{t("permissionPanel.alwaysAllow")}
</Button>
<Button size="sm" variant="destructive" onClick={() => respondPermission("reject")}>
{t("permissionPanel.reject")}
</Button>
</div>
</div>
)}

{capabilities?.questions && pendingQuestion && (
<QuestionPanel
questions={pendingQuestion.questions}
onSubmit={(answers) => replyQuestion(answers)}
onDismiss={() => rejectQuestion()}
/>
)}
<InteractionRequestsView
permission={capabilities?.permissions ? pendingPermission : null}
question={capabilities?.questions ? pendingQuestion : null}
onRespondPermission={respondPermission}
onReplyQuestion={replyQuestion}
onRejectQuestion={rejectQuestion}
/>
</>
);

Expand Down
19 changes: 11 additions & 8 deletions src/components/SlashCommandPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,39 @@
* Shows available slash commands filtered by the current input.
*/

import type { Command } from "@opencode-ai/sdk/v2/client";
import { cn } from "@/lib/utils";
import type { SlashCommandResource } from "@/protocol/harness-resources";

interface SlashCommandPopoverProps {
commands: Command[];
interface SlashCommandPopoverProps<TCommand extends SlashCommandResource = SlashCommandResource> {
commands: TCommand[];
filter: string;
activeIndex: number;
onSelect: (command: Command) => void;
onSelect: (command: TCommand) => void;
onHover: (index: number) => void;
}

function matchesFilter(command: Command, filter: string): boolean {
function matchesFilter(command: SlashCommandResource, filter: string): boolean {
if (!filter) return true;
const lower = filter.toLowerCase();
if (command.name.toLowerCase().includes(lower)) return true;
if (command.description?.toLowerCase().includes(lower)) return true;
return false;
}

export function useFilteredCommands(commands: Command[], filter: string) {
export function useFilteredCommands<TCommand extends SlashCommandResource>(
commands: TCommand[],
filter: string,
) {
return commands.filter((cmd) => matchesFilter(cmd, filter));
}

export function SlashCommandPopover({
export function SlashCommandPopover<TCommand extends SlashCommandResource>({
commands,
filter,
activeIndex,
onSelect,
onHover,
}: SlashCommandPopoverProps) {
}: SlashCommandPopoverProps<TCommand>) {
const filtered = useFilteredCommands(commands, filter);

if (filtered.length === 0) return null;
Expand Down
Loading
Loading