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
136 changes: 125 additions & 11 deletions ui/desktop/src/components/SessionActionsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { ChevronDown, ChevronRight, Copy, Edit2, FileJson, LoaderCircle } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import {
Activity,
ChevronDown,
ChevronRight,
Copy,
Edit2,
FileJson,
LoaderCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { AppEvents } from '../constants/events';
import { defineMessages, useIntl } from '../i18n';
import { getDiagnosticsReport } from '../acp/diagnostics';
import { acpExportSession, acpForkSession, acpRenameSession } from '../acp/sessions';
import { getSessionDisplayName } from '../sessions';
import type { Session } from '../types/session';
Expand Down Expand Up @@ -34,6 +43,10 @@ const i18n = defineMessages({
id: 'sessionActionsHeader.viewJson',
defaultMessage: 'View session JSON',
},
viewModelInteractions: {
id: 'sessionActionsHeader.viewModelInteractions',
defaultMessage: 'View recent model interactions',
},
renameTitle: {
id: 'sessionActionsHeader.renameTitle',
defaultMessage: 'Rename Session',
Expand Down Expand Up @@ -74,6 +87,10 @@ const i18n = defineMessages({
id: 'sessionActionsHeader.jsonTitle',
defaultMessage: 'Session JSON',
},
modelInteractionsTitle: {
id: 'sessionActionsHeader.modelInteractionsTitle',
defaultMessage: 'Recent model interactions',
},
loadingJson: {
id: 'sessionActionsHeader.loadingJson',
defaultMessage: 'Loading JSON...',
Expand All @@ -82,6 +99,10 @@ const i18n = defineMessages({
id: 'sessionActionsHeader.jsonFailed',
defaultMessage: 'Failed to load session JSON: {error}',
},
modelInteractionsFailed: {
id: 'sessionActionsHeader.modelInteractionsFailed',
defaultMessage: 'Failed to load model interactions: {error}',
},
close: {
id: 'sessionActionsHeader.close',
defaultMessage: 'Close',
Expand All @@ -94,6 +115,10 @@ const i18n = defineMessages({
id: 'sessionActionsHeader.copiedJson',
defaultMessage: 'Session JSON copied',
},
copiedModelInteractions: {
id: 'sessionActionsHeader.copiedModelInteractions',
defaultMessage: 'Recent model interactions copied',
},
fullTextTitle: {
id: 'sessionActionsHeader.fullTextTitle',
defaultMessage: 'Text value',
Expand Down Expand Up @@ -128,6 +153,8 @@ interface FullTextSelection {
value: string;
}

type JsonDialogKind = 'session' | 'modelInteractions';

function parseSessionJson(json: string): ParsedSessionJson {
const value = JSON.parse(json) as unknown;
return {
Expand All @@ -136,6 +163,14 @@ function parseSessionJson(json: string): ParsedSessionJson {
};
}

function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return line;
}
}

function isJsonRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -319,11 +354,14 @@ export default function SessionActionsHeader({
const [renameValue, setRenameValue] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [isJsonOpen, setIsJsonOpen] = useState(false);
const [jsonDialogKind, setJsonDialogKind] = useState<JsonDialogKind>('session');
const [jsonValue, setJsonValue] = useState<unknown>(null);
const [jsonText, setJsonText] = useState('');
const [isJsonLoading, setIsJsonLoading] = useState(false);
const [isModelInteractionsLoading, setIsModelInteractionsLoading] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [fullTextSelection, setFullTextSelection] = useState<FullTextSelection | null>(null);
const jsonLoadRequestId = useRef(0);

const title = useMemo(() => (session ? getSessionDisplayName(session) : ''), [session]);

Expand Down Expand Up @@ -388,32 +426,82 @@ export default function SessionActionsHeader({
const handleViewJson = useCallback(async () => {
if (!session) return;

const loadRequestId = ++jsonLoadRequestId.current;
setIsJsonOpen(true);
setJsonDialogKind('session');
setJsonValue(null);
setJsonText('');
setIsModelInteractionsLoading(false);
setIsJsonLoading(true);
try {
const json = await acpExportSession(session.id);
const parsed = parseSessionJson(json);
if (jsonLoadRequestId.current !== loadRequestId) return;
setJsonValue(parsed.value);
setJsonText(parsed.pretty);
} catch (error) {
if (jsonLoadRequestId.current !== loadRequestId) return;
setIsJsonOpen(false);
toast.error(
intl.formatMessage(i18n.jsonFailed, {
error: errorMessage(error, 'Unknown error'),
})
);
} finally {
setIsJsonLoading(false);
if (jsonLoadRequestId.current === loadRequestId) {
setIsJsonLoading(false);
}
}
}, [intl, session]);

const handleViewModelInteractions = useCallback(async () => {
if (!session) return;

const loadRequestId = ++jsonLoadRequestId.current;
setIsJsonOpen(true);
setJsonDialogKind('modelInteractions');
setJsonValue(null);
setJsonText('');
setIsJsonLoading(false);
setIsModelInteractionsLoading(true);
try {
const report = await getDiagnosticsReport(session.id, 'full');
const interactions = report.logs.llm.map((log) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter model interaction logs by session

Opening this from a session action maps every LLM log returned by the diagnostics report, but the backend fills logs.llm from recent_llm_log_paths() (crates/goose/src/session/diagnostics.rs:321) rather than filtering by the session.id passed here. When another session has one of the 10 most recent LLM requests, this viewer will show and copy that other session's prompts/responses under the current session, so the UI needs a per-session log source or an explicit filter.

Useful? React with 👍 / 👎.

path: log.path,
truncated: log.truncated,
entries: log.content
.split('\n')
.filter((line) => line.trim().length > 0)
.map(parseJsonLine),
}));
const text = JSON.stringify(interactions, null, 2);
if (jsonLoadRequestId.current !== loadRequestId) return;
setJsonValue(interactions);
setJsonText(text);
Comment on lines +479 to +480

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore stale dialog loads before updating JSON state

If a user starts loading model interactions, closes the dialog, and opens session JSON before the diagnostics call finishes, this completion still writes jsonValue/jsonText after the newer load because both dialog modes share the same state and the async result is not tied to the current kind/session. That can leave a dialog titled "Session JSON" displaying/copying model-interaction data (or the reverse); guard completions with a request id/current kind or cancel the previous load.

Useful? React with 👍 / 👎.

} catch (error) {
if (jsonLoadRequestId.current !== loadRequestId) return;
setIsJsonOpen(false);
toast.error(
intl.formatMessage(i18n.modelInteractionsFailed, {
error: errorMessage(error, 'Unknown error'),
})
);
} finally {
if (jsonLoadRequestId.current === loadRequestId) {
setIsModelInteractionsLoading(false);
}
}
}, [intl, session]);

const handleCopyJson = useCallback(async () => {
if (!jsonText) return;
await navigator.clipboard.writeText(jsonText);
toast.success(intl.formatMessage(i18n.copiedJson));
}, [intl, jsonText]);
toast.success(
intl.formatMessage(
jsonDialogKind === 'modelInteractions' ? i18n.copiedModelInteractions : i18n.copiedJson
)
);
}, [intl, jsonDialogKind, jsonText]);

const handleCopyFullText = useCallback(async () => {
if (!fullTextSelection) return;
Expand All @@ -424,6 +512,9 @@ export default function SessionActionsHeader({
const handleJsonOpenChange = useCallback((open: boolean) => {
setIsJsonOpen(open);
if (!open) {
jsonLoadRequestId.current += 1;
setIsJsonLoading(false);
setIsModelInteractionsLoading(false);
setFullTextSelection(null);
}
}, []);
Expand All @@ -441,19 +532,22 @@ export default function SessionActionsHeader({
return null;
}

const isDialogOpen = isRenameOpen || isJsonOpen || fullTextSelection !== null;

return (
<>
<div
className={cn(
'no-drag absolute top-[14px] left-1/2 z-30 max-w-[min(36rem,calc(100vw-13rem))] -translate-x-1/2',
'no-drag absolute top-[14px] left-1/2 max-w-[min(36rem,calc(100vw-13rem))] -translate-x-1/2',
isDialogOpen ? 'z-30' : 'z-50',
className
)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 max-w-full items-center gap-1 rounded-md px-2.5 text-text-primary transition-colors hover:bg-background-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-active"
className="no-drag flex h-7 max-w-full items-center gap-1 rounded-md px-2.5 text-text-primary transition-colors hover:bg-background-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-active"
aria-label={intl.formatMessage(i18n.actionsLabel)}
>
<span className="truncate text-xs font-medium">{title}</span>
Expand Down Expand Up @@ -481,6 +575,14 @@ export default function SessionActionsHeader({
)}
{intl.formatMessage(i18n.viewJson)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleViewModelInteractions()}>
{isModelInteractionsLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Activity className="size-4" />
)}
{intl.formatMessage(i18n.viewModelInteractions)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand All @@ -505,7 +607,10 @@ export default function SessionActionsHeader({
<Button variant="outline" onClick={() => setIsRenameOpen(false)} disabled={isRenaming}>
{intl.formatMessage(i18n.cancel)}
</Button>
<Button onClick={() => void handleRename()} disabled={isRenaming || !renameValue.trim()}>
<Button
onClick={() => void handleRename()}
disabled={isRenaming || !renameValue.trim()}
>
{isRenaming ? intl.formatMessage(i18n.saving) : intl.formatMessage(i18n.save)}
</Button>
</DialogFooter>
Expand All @@ -515,10 +620,16 @@ export default function SessionActionsHeader({
<Dialog open={isJsonOpen} onOpenChange={handleJsonOpenChange}>
<DialogContent className="grid max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{intl.formatMessage(i18n.jsonTitle)}</DialogTitle>
<DialogTitle>
{intl.formatMessage(
jsonDialogKind === 'modelInteractions'
? i18n.modelInteractionsTitle
: i18n.jsonTitle
)}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-hidden rounded-lg border border-border-primary bg-background-secondary">
{isJsonLoading ? (
{isJsonLoading || isModelInteractionsLoading ? (
<div className="flex h-64 items-center justify-center gap-2 text-sm text-text-secondary">
<LoaderCircle className="size-4 animate-spin" />
{intl.formatMessage(i18n.loadingJson)}
Expand All @@ -533,7 +644,10 @@ export default function SessionActionsHeader({
<Button variant="outline" onClick={() => setIsJsonOpen(false)}>
{intl.formatMessage(i18n.close)}
</Button>
<Button onClick={() => void handleCopyJson()} disabled={!jsonText || isJsonLoading}>
<Button
onClick={() => void handleCopyJson()}
disabled={!jsonText || isJsonLoading || isModelInteractionsLoading}
>
{intl.formatMessage(i18n.copyJson)}
</Button>
</DialogFooter>
Expand Down
12 changes: 12 additions & 0 deletions ui/desktop/src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -3662,6 +3662,9 @@
"sessionActionsHeader.copiedJson": {
"defaultMessage": "Sitzungs-JSON kopiert"
},
"sessionActionsHeader.copiedModelInteractions": {
"defaultMessage": "Recent model interactions copied"
},
"sessionActionsHeader.copiedText": {
"defaultMessage": "Text kopiert"
},
Expand Down Expand Up @@ -3692,6 +3695,12 @@
"sessionActionsHeader.loadingJson": {
"defaultMessage": "JSON wird geladen..."
},
"sessionActionsHeader.modelInteractionsFailed": {
"defaultMessage": "Failed to load model interactions: {error}"
},
"sessionActionsHeader.modelInteractionsTitle": {
"defaultMessage": "Recent model interactions"
},
"sessionActionsHeader.renameFailed": {
"defaultMessage": "Sitzung konnte nicht umbenannt werden: {error}"
},
Expand All @@ -3716,6 +3725,9 @@
"sessionActionsHeader.viewJson": {
"defaultMessage": "Sitzungs-JSON anzeigen"
},
"sessionActionsHeader.viewModelInteractions": {
"defaultMessage": "View recent model interactions"
},
"sessionIndicators.error": {
"defaultMessage": "In der Sitzung ist ein Fehler aufgetreten"
},
Expand Down
12 changes: 12 additions & 0 deletions ui/desktop/src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3662,6 +3662,9 @@
"sessionActionsHeader.copiedJson": {
"defaultMessage": "Session JSON copied"
},
"sessionActionsHeader.copiedModelInteractions": {
"defaultMessage": "Recent model interactions copied"
},
"sessionActionsHeader.copiedText": {
"defaultMessage": "Text copied"
},
Expand Down Expand Up @@ -3692,6 +3695,12 @@
"sessionActionsHeader.loadingJson": {
"defaultMessage": "Loading JSON..."
},
"sessionActionsHeader.modelInteractionsFailed": {
"defaultMessage": "Failed to load model interactions: {error}"
},
"sessionActionsHeader.modelInteractionsTitle": {
"defaultMessage": "Recent model interactions"
},
"sessionActionsHeader.renameFailed": {
"defaultMessage": "Failed to rename session: {error}"
},
Expand All @@ -3716,6 +3725,9 @@
"sessionActionsHeader.viewJson": {
"defaultMessage": "View session JSON"
},
"sessionActionsHeader.viewModelInteractions": {
"defaultMessage": "View recent model interactions"
},
"sessionIndicators.error": {
"defaultMessage": "Session encountered an error"
},
Expand Down
12 changes: 12 additions & 0 deletions ui/desktop/src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -3662,6 +3662,9 @@
"sessionActionsHeader.copiedJson": {
"defaultMessage": "JSON de la sesión copiado"
},
"sessionActionsHeader.copiedModelInteractions": {
"defaultMessage": "Recent model interactions copied"
},
"sessionActionsHeader.copiedText": {
"defaultMessage": "Texto copiado"
},
Expand Down Expand Up @@ -3692,6 +3695,12 @@
"sessionActionsHeader.loadingJson": {
"defaultMessage": "Cargando JSON..."
},
"sessionActionsHeader.modelInteractionsFailed": {
"defaultMessage": "Failed to load model interactions: {error}"
},
"sessionActionsHeader.modelInteractionsTitle": {
"defaultMessage": "Recent model interactions"
},
"sessionActionsHeader.renameFailed": {
"defaultMessage": "No se pudo renombrar la sesión: {error}"
},
Expand All @@ -3716,6 +3725,9 @@
"sessionActionsHeader.viewJson": {
"defaultMessage": "Ver el JSON de la sesión"
},
"sessionActionsHeader.viewModelInteractions": {
"defaultMessage": "View recent model interactions"
},
"sessionIndicators.error": {
"defaultMessage": "La sesión encontró un error"
},
Expand Down
Loading
Loading