diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts
index c33c900836e..ddd164a54d2 100644
--- a/ui/src/adapters/transcript.test.ts
+++ b/ui/src/adapters/transcript.test.ts
@@ -70,4 +70,116 @@ describe("buildTranscript", () => {
expect(first).toEqual([]);
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
});
+
+ it("converts parser failures into transcript error entries and keeps going", () => {
+ const entries = buildTranscript(
+ [
+ { ts, stream: "stdout", chunk: "ok\nexplode\nlater\n" },
+ ],
+ (line, entryTs) => {
+ if (line === "explode") {
+ throw new Error("boom");
+ }
+ return [{ kind: "stdout", ts: entryTs, text: line }];
+ },
+ );
+
+ expect(entries).toEqual([
+ { kind: "stdout", ts, text: "ok" },
+ {
+ kind: "result",
+ ts,
+ text: "Chat transcript error: boom. Falling back for line: explode",
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0,
+ subtype: "transcript_parse_error",
+ isError: true,
+ errors: [],
+ },
+ { kind: "stdout", ts, text: "later" },
+ ]);
+ });
+
+ it("resets stateful parsers after a failure before parsing later lines", () => {
+ const statefulAdapter: UIAdapterModule = {
+ type: "stateful_test",
+ label: "Stateful Test",
+ parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
+ createStdoutParser: () => {
+ let pending: string | null = null;
+ return {
+ parseLine: (line, entryTs) => {
+ if (line.startsWith("begin:")) {
+ pending = line.slice("begin:".length);
+ return [];
+ }
+ if (line === "explode") {
+ throw new Error(`bad state:${pending ?? "none"}`);
+ }
+ if (line === "finish" && pending) {
+ const text = `completed:${pending}`;
+ pending = null;
+ return [{ kind: "stdout", ts: entryTs, text }];
+ }
+ return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
+ },
+ reset: () => {
+ pending = null;
+ },
+ };
+ },
+ ConfigFields: () => null,
+ buildAdapterConfig: () => ({}),
+ };
+
+ const entries = buildTranscript(
+ [{ ts, stream: "stdout", chunk: "begin:task-a\nexplode\nfinish\n" }],
+ statefulAdapter,
+ );
+
+ expect(entries).toEqual([
+ {
+ kind: "result",
+ ts,
+ text: "Chat transcript error: bad state:task-a. Falling back for line: explode",
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0,
+ subtype: "transcript_parse_error",
+ isError: true,
+ errors: [],
+ },
+ { kind: "stdout", ts, text: "literal:finish" },
+ ]);
+ });
+
+ it("handles trailing buffered parser failures without throwing", () => {
+ const entries = buildTranscript(
+ [{ ts, stream: "stdout", chunk: "explode" }],
+ (line, entryTs) => {
+ if (line === "explode") {
+ throw new Error("trailing boom");
+ }
+ return [{ kind: "stdout", ts: entryTs, text: line }];
+ },
+ );
+
+ expect(entries).toEqual([
+ {
+ kind: "result",
+ ts,
+ text: "Chat transcript error: trailing boom. Falling back for line: explode",
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0,
+ subtype: "transcript_parse_error",
+ isError: true,
+ errors: [],
+ },
+ ]);
+ });
});
diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts
index 307aa5ae44b..95a81707dde 100644
--- a/ui/src/adapters/transcript.ts
+++ b/ui/src/adapters/transcript.ts
@@ -3,6 +3,7 @@ import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
+type RedactionOptions = { enabled: boolean };
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
if (typeof source === "function") {
@@ -33,6 +34,66 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
}
}
+function truncateTranscriptLine(line: string, maxLength = 160) {
+ if (line.length <= maxLength) return line;
+ return `${line.slice(0, maxLength - 3)}...`;
+}
+
+function formatTranscriptParserError(error: unknown) {
+ if (error instanceof Error && error.message) return error.message;
+ if (typeof error === "string" && error) return error;
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return String(error);
+ }
+}
+
+function createTranscriptParseErrorEntry(
+ line: string,
+ ts: string,
+ error: unknown,
+ redactionOptions: RedactionOptions,
+): TranscriptEntry {
+ const errorText = formatTranscriptParserError(error) || "unknown parser error";
+ const preview = truncateTranscriptLine(line);
+ return {
+ kind: "result",
+ ts,
+ text: redactHomePathUserSegments(
+ `Chat transcript error: ${errorText}. Falling back for line: ${preview}`,
+ redactionOptions,
+ ),
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0,
+ subtype: "transcript_parse_error",
+ isError: true,
+ errors: [],
+ };
+}
+
+function appendParsedTranscriptLine(args: {
+ entries: TranscriptEntry[];
+ line: string;
+ ts: string;
+ parseLine: (line: string, ts: string) => TranscriptEntry[];
+ reset: (() => void) | null;
+ redactionOptions: RedactionOptions;
+}) {
+ const { entries, line, ts, parseLine, reset, redactionOptions } = args;
+ try {
+ appendTranscriptEntries(
+ entries,
+ parseLine(line, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)),
+ );
+ } catch (error) {
+ reset?.();
+ appendTranscriptEntry(entries, createTranscriptParseErrorEntry(line, ts, error, redactionOptions));
+ }
+}
+
export function buildTranscript(
chunks: RunLogChunk[],
parserSource: StdoutLineParser | TranscriptParserSource,
@@ -59,14 +120,28 @@ export function buildTranscript(
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
- appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
+ appendParsedTranscriptLine({
+ entries,
+ line: trimmed,
+ ts: chunk.ts,
+ parseLine,
+ reset,
+ redactionOptions,
+ });
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
- appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
+ appendParsedTranscriptLine({
+ entries,
+ line: trailing,
+ ts,
+ parseLine,
+ reset,
+ redactionOptions,
+ });
}
reset?.();
diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx
index 02e0d4b40ee..f4ad17b5233 100644
--- a/ui/src/components/CommentThread.tsx
+++ b/ui/src/components/CommentThread.tsx
@@ -369,7 +369,7 @@ function CommentCard({
+ {description} +
+ ) : null} +Unable to load issue preview.
+ ) : null} +{preChildren};
},
a: ({ href, children: linkChildren }) => {
+ const issueRef = parseIssueReferenceFromHref(href);
+ if (issueRef) {
+ return (
+
+ {linkChildren}
+
+ );
+ }
+
const parsed = href ? parseMentionChipHref(href) : null;
if (parsed) {
const targetHref = parsed.kind === "project"
@@ -159,7 +175,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
)}
style={style}
>
-
{file.content}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 75e28bbbc88..a3495d8947f 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -21,7 +21,6 @@ import { queryKeys } from "../lib/queryKeys";
import {
applyIssueFilters,
countActiveIssueFilters,
- defaultIssueFilterState,
type IssueFilterState,
} from "../lib/issue-filters";
import {
@@ -31,7 +30,12 @@ import {
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
-import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
+import {
+ hasBlockingShortcutDialog,
+ isKeyboardShortcutTextInputTarget,
+ shouldBlurPageSearchOnEnter,
+ shouldBlurPageSearchOnEscape,
+} from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import {
@@ -91,13 +95,16 @@ import {
buildInboxNesting,
getAvailableInboxIssueColumns,
getApprovalsForTab,
+ getArchivedInboxSearchIssues,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
+ matchesInboxIssueSearch,
getRecentTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed,
isMineInboxTab,
+ loadInboxFilterPreferences,
loadInboxIssueColumns,
loadInboxNesting,
loadInboxWorkItemGroupBy,
@@ -105,10 +112,13 @@ import {
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
+ saveInboxFilterPreferences,
saveInboxIssueColumns,
saveInboxNesting,
saveInboxWorkItemGroupBy,
type InboxApprovalFilter,
+ type InboxCategoryFilter,
+ type InboxFilterPreferences,
type InboxIssueColumn,
saveLastInboxTab,
shouldShowInboxSection,
@@ -119,14 +129,6 @@ import {
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
-
-type InboxCategoryFilter =
- | "everything"
- | "issues_i_touched"
- | "join_requests"
- | "approvals"
- | "failed_runs"
- | "alerts";
type SectionKey =
| "work_items"
| "alerts";
@@ -141,8 +143,32 @@ type InboxGroupedSection = {
label: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: Map;
+ isArchivedSearch: boolean;
};
+function buildGroupedInboxSections(
+ items: InboxWorkItem[],
+ groupBy: InboxWorkItemGroupBy,
+ nestingEnabled: boolean,
+ options?: { keyPrefix?: string; isArchivedSearch?: boolean },
+): InboxGroupedSection[] {
+ const keyPrefix = options?.keyPrefix ?? "";
+ const isArchivedSearch = options?.isArchivedSearch ?? false;
+ return groupInboxWorkItems(items, groupBy).map((group) => {
+ const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
+ ? buildInboxNesting(group.items)
+ : { displayItems: group.items, childrenByIssueId: new Map() };
+
+ return {
+ key: `${keyPrefix}${group.key}`,
+ label: group.label,
+ displayItems: nestedGroup.displayItems,
+ childrenByIssueId: nestedGroup.childrenByIssueId,
+ isArchivedSearch,
+ };
+ });
+}
+
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -616,14 +642,15 @@ export function Inbox() {
retry: false,
});
const [searchQuery, setSearchQuery] = useState("");
- const [allCategoryFilter, setAllCategoryFilter] = useState("everything");
- const [allApprovalFilter, setAllApprovalFilter] = useState("all");
- const [issueFilters, setIssueFilters] = useState(defaultIssueFilterState);
+ const [filterPreferences, setFilterPreferences] = useState(
+ () => loadInboxFilterPreferences(selectedCompanyId),
+ );
const [groupBy, setGroupBy] = useState(() => loadInboxWorkItemGroupBy());
const [visibleIssueColumns, setVisibleIssueColumns] = useState(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
+ const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
@@ -681,6 +708,14 @@ export function Inbox() {
setSearchQuery("");
}, [tab]);
+ const previousSelectedCompanyIdRef = useRef(selectedCompanyId);
+ useEffect(() => {
+ if (previousSelectedCompanyIdRef.current !== selectedCompanyId) {
+ previousSelectedCompanyIdRef.current = selectedCompanyId;
+ setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
+ }
+ }, [selectedCompanyId]);
+
const {
data: approvals,
isLoading: isApprovalsLoading,
@@ -909,19 +944,12 @@ export function Inbox() {
if (!q) return workItemsToRender;
return workItemsToRender.filter((item) => {
if (item.kind === "issue") {
- const issue = item.issue;
- if (issue.title.toLowerCase().includes(q)) return true;
- if (issue.identifier?.toLowerCase().includes(q)) return true;
- if (issue.description?.toLowerCase().includes(q)) return true;
- if (isolatedWorkspacesEnabled) {
- const workspaceName = resolveIssueWorkspaceName(issue, {
- executionWorkspaceById,
- projectWorkspaceById,
- defaultProjectWorkspaceIdByProjectId,
- });
- if (workspaceName?.toLowerCase().includes(q)) return true;
- }
- return false;
+ return matchesInboxIssueSearch(item.issue, q, {
+ isolatedWorkspacesEnabled,
+ executionWorkspaceById,
+ projectWorkspaceById,
+ defaultProjectWorkspaceIdByProjectId,
+ });
}
if (item.kind === "approval") {
const a = item.approval;
@@ -963,6 +991,35 @@ export function Inbox() {
projectWorkspaceById,
]);
+ const archivedSearchIssues = useMemo(
+ () =>
+ tab === "mine"
+ ? getArchivedInboxSearchIssues({
+ visibleIssues: visibleMineIssues,
+ searchableIssues: visibleTouchedIssues,
+ query: searchQuery,
+ isolatedWorkspacesEnabled,
+ executionWorkspaceById,
+ projectWorkspaceById,
+ defaultProjectWorkspaceIdByProjectId,
+ })
+ : [],
+ [
+ defaultProjectWorkspaceIdByProjectId,
+ executionWorkspaceById,
+ isolatedWorkspacesEnabled,
+ projectWorkspaceById,
+ searchQuery,
+ tab,
+ visibleMineIssues,
+ visibleTouchedIssues,
+ ],
+ );
+ const archivedSearchIssueIds = useMemo(
+ () => new Set(archivedSearchIssues.map((issue) => issue.id)),
+ [archivedSearchIssues],
+ );
+
// --- Parent-child nesting for inbox issues ---
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
@@ -974,33 +1031,15 @@ export function Inbox() {
});
}, []);
const [collapsedInboxParents, setCollapsedInboxParents] = useState>(new Set());
- const groupedSections = useMemo(() => {
- return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => {
- const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
- ? buildInboxNesting(group.items)
- : { displayItems: group.items, childrenByIssueId: new Map() };
-
- return {
- key: group.key,
- label: group.label,
- displayItems: nestedGroup.displayItems,
- childrenByIssueId: nestedGroup.childrenByIssueId,
- };
- });
- }, [filteredWorkItems, groupBy, nestingEnabled]);
- const nestedWorkItems = useMemo(
- () => groupedSections.flatMap((group) => group.displayItems),
- [groupedSections],
- );
- const childrenByIssueId = useMemo(() => {
- const merged = new Map();
- for (const group of groupedSections) {
- for (const [issueId, children] of group.childrenByIssueId) {
- merged.set(issueId, children);
- }
- }
- return merged;
- }, [groupedSections]);
+ const groupedSections = useMemo(() => [
+ ...buildGroupedInboxSections(filteredWorkItems, groupBy, nestingEnabled),
+ ...buildGroupedInboxSections(
+ getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
+ groupBy,
+ nestingEnabled,
+ { keyPrefix: "archived-search:", isArchivedSearch: true },
+ ),
+ ], [archivedSearchIssues, filteredWorkItems, groupBy, nestingEnabled]);
const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections],
@@ -1052,9 +1091,28 @@ export function Inbox() {
}
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
}, [setIssueColumns, visibleIssueColumns]);
+ const updateFilterPreferences = useCallback(
+ (updater: (previous: InboxFilterPreferences) => InboxFilterPreferences) => {
+ setFilterPreferences((previous) => {
+ const next = updater(previous);
+ saveInboxFilterPreferences(selectedCompanyId, next);
+ return next;
+ });
+ },
+ [selectedCompanyId],
+ );
const updateIssueFilters = useCallback((patch: Partial) => {
- setIssueFilters((previous) => ({ ...previous, ...patch }));
- }, []);
+ updateFilterPreferences((previous) => ({
+ ...previous,
+ issueFilters: { ...previous.issueFilters, ...patch },
+ }));
+ }, [updateFilterPreferences]);
+ const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
+ updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
+ }, [updateFilterPreferences]);
+ const updateAllApprovalFilter = useCallback((value: InboxApprovalFilter) => {
+ updateFilterPreferences((previous) => ({ ...previous, allApprovalFilter: value }));
+ }, [updateFilterPreferences]);
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
setGroupBy(nextGroupBy);
saveInboxWorkItemGroupBy(nextGroupBy);
@@ -1325,6 +1383,7 @@ export function Inbox() {
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
+ archivedSearchIssueIds,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
@@ -1335,6 +1394,7 @@ export function Inbox() {
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
+ archivedSearchIssueIds,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
@@ -1415,10 +1475,12 @@ export function Inbox() {
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
- if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
+ if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) {
if (item.kind === "issue") {
- if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
+ if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
+ act.archiveIssue(item.issue.id);
+ }
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
@@ -1553,10 +1615,28 @@ export function Inbox() {
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (shouldBlurPageSearchOnEnter({
+ key: e.key,
+ isComposing: e.nativeEvent.isComposing,
+ })) {
+ e.currentTarget.blur();
+ return;
+ }
+
+ if (shouldBlurPageSearchOnEscape({
+ key: e.key,
+ isComposing: e.nativeEvent.isComposing,
+ currentValue: e.currentTarget.value,
+ })) {
+ e.currentTarget.blur();
+ }
+ }}
className="h-8 w-full pl-8 text-xs"
+ data-page-search-target="true"
/>