diff --git a/.github/checks-manifest.yaml b/.github/checks-manifest.yaml index 7c200238617..a98da02fe6c 100644 --- a/.github/checks-manifest.yaml +++ b/.github/checks-manifest.yaml @@ -264,6 +264,16 @@ checks: triggers: ["desktop/macos/Desktop/Sources/**/*.swift", "desktop/macos/scripts/check-grdb-insert-idiom.py", ".github/checks-manifest.yaml"] lanes: ["local", "ci"] reason: "#11204: a mutating didInsert never witnesses PersistableRecord's non-mutating insert, so a direct record.insert(db) drops the rowid silently onto an optional field; two shipped instances went undetected until unrelated work tripped over them" + - id: desktop-single-chat-shell-self-test + command: ["python3", "desktop/macos/scripts/check-single-chat-shell.py", "--self-test"] + triggers: ["desktop/macos/scripts/check-single-chat-shell.py", ".github/checks-manifest.yaml"] + lanes: ["local", "ci"] + reason: "the tripwire must keep failing on each shape it was written for, and keep passing on prose that merely names one" + - id: desktop-single-chat-shell + command: ["python3", "desktop/macos/scripts/check-single-chat-shell.py"] + triggers: ["desktop/macos/Desktop/Sources/**/*.swift", "desktop/macos/scripts/check-single-chat-shell.py", ".github/checks-manifest.yaml"] + lanes: ["local", "ci"] + reason: "#12598: the app mounted one of two shells behind a preference, and six content-block kinds rendered as controls on one and as nothing on the other; both grow back one symbol at a time" - id: brand-ui-ratchet-tests command: ["python3", ".github/scripts/test_check_brand_ui.py"] triggers: [".github/scripts/check_brand_ui.py", ".github/scripts/test_check_brand_ui.py", ".github/checks-manifest.yaml"] diff --git a/.github/failure-classes/FC-presentation-cohort-drops-journaled-content.json b/.github/failure-classes/FC-presentation-cohort-drops-journaled-content.json new file mode 100644 index 00000000000..d41d0750aa7 --- /dev/null +++ b/.github/failure-classes/FC-presentation-cohort-drops-journaled-content.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "id": "FC-presentation-cohort-drops-journaled-content", + "violated_contract": "A client-side presentation cohort must never decide whether durable, journaled content is drawn. The macOS chat journaled taskCard, goalLink, captureLink, conversationLink, memoryLink and questionCard blocks for every account, but only the shell sampled into the chat-first cohort was handed a ChatFirstRichBlockContext; every other host (legacy shell, task chat panel, floating bar, notch) decoded the same blocks and rendered EmptyView. A task created during onboarding was therefore in the transcript and invisible, and the same transcript looked different per shell, per cohort, and per surface. The defect reads as 'the task was never created', not as a rendering gate, because the journal is correct.", + "canonical_prevention": "One renderer, no optional rendering context: every chat surface receives a non-optional rich-block context, and a behavioural test journals one message carrying every block type and asserts the shared renderer yields the interactable view for each. A static tripwire forbids the retired cohort/legacy symbols and any 'context == nil' branch from returning. Capability gates may disable an action on a card; they may not decide whether the card exists.", + "canonical_prevention_artifact": [ + "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift", + "desktop/macos/Desktop/Tests/OneChatShellRichBlockTests.swift", + "desktop/macos/scripts/check-single-chat-shell.py", + "app/test/widgets/chat_content_blocks_test.dart" + ], + "scope_hints": [ + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift", + "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/**", + "app/lib/pages/chat/widgets/**" + ], + "status": "open", + "evidence_prs": [] +} diff --git a/.github/scripts/check_chat_selection_boundary.py b/.github/scripts/check_chat_selection_boundary.py index 0b8bce7df55..25c76d96be7 100644 --- a/.github/scripts/check_chat_selection_boundary.py +++ b/.github/scripts/check_chat_selection_boundary.py @@ -9,8 +9,14 @@ SwiftUI has no type-level API that prevents an ancestor or message renderer from installing SelectionOverlay. This deliberately narrow source tripwire -therefore protects the three authoritative live-transcript files. Behavioral -resize coverage remains in ChatTimelineContinuityTests. +therefore protects the authoritative live-transcript files. Behavioral resize +coverage remains in ChatTimelineContinuityTests. + +The bar is on `SelectionOverlay`, not on selecting. The transcript now hosts +selection through `ChatSelectableProse` — one `NSTextView` per prose block, +which *is* its own selection and installs no per-`Text` overlay for a parent +rebuild to thrash. That file is protected here too, so the AppKit path can +never quietly acquire the SwiftUI one. """ from __future__ import annotations @@ -25,13 +31,15 @@ "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift", "desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift", "desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift", + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift", ) MARKDOWN_FILE = LIVE_TRANSCRIPT_FILES[2] +SELECTION_FILE = LIVE_TRANSCRIPT_FILES[3] FORBIDDEN_PATTERNS = { ".textSelection(.enabled)": ( - "live chat must not install SwiftUI SelectionOverlay; use the existing copy actions " - "or a separate non-live reading surface" + "live chat must not install SwiftUI SelectionOverlay; selection belongs to " + "ChatSelectableProse, whose NSTextView owns it without one" ), "textSelectionEnabled": ( "OmiMarkdown must not expose a native-selection escape hatch" @@ -59,6 +67,15 @@ def check_sources(sources: Mapping[str, str]) -> list[str]: f"{MARKDOWN_FILE}: OmiMarkdown must explicitly disable inherited native text selection" ) + # The sanctioned remedy has to stay AppKit. An NSTextView owning its own + # selection is the whole reason selection is allowed back into the + # transcript; a SwiftUI Text here would reopen the failure class. + selection_source = sources.get(SELECTION_FILE) + if selection_source is not None and "NSTextView" not in selection_source: + failures.append( + f"{SELECTION_FILE}: transcript selection must be hosted by an NSTextView" + ) + return failures diff --git a/.github/scripts/test_check_chat_selection_boundary.py b/.github/scripts/test_check_chat_selection_boundary.py index 60b5502a910..1813527884f 100644 --- a/.github/scripts/test_check_chat_selection_boundary.py +++ b/.github/scripts/test_check_chat_selection_boundary.py @@ -28,6 +28,11 @@ def clean_sources() -> dict[str, str]: CHECKER.MARKDOWN_FILE: ( "struct OmiMarkdown { var body: some View { Text(text).textSelection(.disabled) } }\n" ), + CHECKER.SELECTION_FILE: ( + "struct ChatSelectableProseText: NSViewRepresentable {\n" + " func makeNSView(context: Context) -> NSTextView { ChatProseTextView() }\n" + "}\n" + ), } @@ -61,6 +66,19 @@ def test_requires_explicit_disabled_boundary(self) -> None: self.assertTrue(any("explicitly disable" in failure for failure in failures)) + def test_requires_the_selection_surface_to_stay_appkit(self) -> None: + """The remedy is an NSTextView owning its own selection. A SwiftUI + rewrite of this file would put SelectionOverlay back in the transcript + under a name the pattern check cannot see.""" + sources = clean_sources() + sources[CHECKER.SELECTION_FILE] = ( + "struct ChatSelectableProseText: View { var body: some View { Text(text) } }\n" + ) + + failures = CHECKER.check_sources(sources) + + self.assertTrue(any("NSTextView" in failure for failure in failures)) + def test_rejects_missing_protected_source(self) -> None: sources = clean_sources() missing = CHECKER.LIVE_TRANSCRIPT_FILES[1] diff --git a/app/lib/backend/schema/chat_content_block.dart b/app/lib/backend/schema/chat_content_block.dart new file mode 100644 index 00000000000..7e2c96177da --- /dev/null +++ b/app/lib/backend/schema/chat_content_block.dart @@ -0,0 +1,457 @@ +/// Typed projection of the chat `content_blocks` wire array. +/// +/// The canonical schema is owned by the macOS agent runtime +/// (`desktop/macos/agent/src/runtime/types.ts` `ConversationContentBlock`) and +/// mirrored by the Swift codec (`ChatContentBlockCodec`). This decoder follows +/// the same required-field rules so a block that macOS drops is dropped here +/// too, and vice versa. +/// +/// Two wire dialects reach mobile: camelCase (desktop/agent, stored verbatim by +/// the backend) and snake_case (validated chat-first specs). Every field is read +/// in both dialects. A malformed block decodes to `null` and is dropped; an +/// unrecognised `type` decodes to [UnknownContentBlock] so the message keeps its +/// text fallback instead of losing content. +library; + +sealed class ChatContentBlock { + const ChatContentBlock({required this.id}); + + final String id; + + /// Canonical wire type name (camelCase), used for widget keys. + String get type; + + /// Decodes a raw wire array, dropping malformed entries. + static List decodeList(List> raw) { + final blocks = []; + for (final entry in raw) { + final block = tryDecode(entry); + if (block != null) blocks.add(block); + } + return List.unmodifiable(blocks); + } + + static ChatContentBlock? tryDecode(Map raw) { + final type = _string(raw, 'type'); + final id = _string(raw, 'id'); + if (type == null || id == null) return null; + + switch (type) { + case 'text': + return TextContentBlock(id: id, text: _string(raw, 'text') ?? ''); + case 'toolCall': + case 'tool_call': + final name = _string(raw, 'name'); + if (name == null) return null; + return ToolCallContentBlock( + id: id, + name: name, + status: _string(raw, 'status') ?? 'completed', + toolUseId: _string(raw, 'toolUseId', 'tool_use_id'), + inputSummary: _string(raw, 'inputSummary', 'input_summary'), + inputDetails: _string(raw, 'inputDetails', 'input_details'), + output: _string(raw, 'output'), + ); + case 'thinking': + return ThinkingContentBlock(id: id, text: _string(raw, 'text') ?? ''); + case 'discoveryCard': + case 'discovery_card': + return DiscoveryCardContentBlock( + id: id, + title: _string(raw, 'title') ?? '', + summary: _string(raw, 'summary') ?? '', + fullText: _string(raw, 'fullText', 'full_text') ?? '', + ); + case 'questionCard': + case 'question_card': + return _decodeQuestionCard(raw, id); + case 'taskCard': + case 'task_card': + final taskId = _string(raw, 'taskId', 'task_id'); + if (taskId == null) return null; + return TaskCardContentBlock(id: id, taskId: taskId); + case 'goalLink': + case 'goal_link': + final goalId = _string(raw, 'goalId', 'goal_id'); + final summary = _string(raw, 'summary'); + if (goalId == null || summary == null) return null; + return GoalLinkContentBlock(id: id, goalId: goalId, summary: summary); + case 'captureLink': + case 'capture_link': + final conversationId = _string(raw, 'conversationId', 'conversation_id'); + final summary = _string(raw, 'summary'); + if (conversationId == null || summary == null) return null; + return CaptureLinkContentBlock( + id: id, + conversationId: conversationId, + summary: summary, + momentTimestampMs: _int(raw, 'momentTimestampMs', 'moment_timestamp_ms'), + ); + case 'conversationLink': + case 'conversation_link': + final conversationId = _string(raw, 'conversationId', 'conversation_id'); + final summary = _string(raw, 'summary'); + if (conversationId == null || summary == null) return null; + return ConversationLinkContentBlock( + id: id, + conversationId: conversationId, + summary: summary, + recommendedActionItems: _decodeRecommendedActionItems( + raw['recommendedActionItems'] ?? raw['recommended_action_items'], + ), + ); + case 'memoryLink': + case 'memory_link': + final memoryId = _string(raw, 'memoryId', 'memory_id'); + final summary = _string(raw, 'summary'); + if (memoryId == null || summary == null) return null; + return MemoryLinkContentBlock(id: id, memoryId: memoryId, summary: summary); + case 'citation': + final ordinal = _int(raw, 'ordinal'); + final kind = _string(raw, 'kind'); + final sourceId = _string(raw, 'sourceId', 'source_id'); + if (ordinal == null || kind == null || sourceId == null) return null; + return CitationContentBlock( + id: id, + ordinal: ordinal, + kind: kind, + sourceId: sourceId, + title: _string(raw, 'title'), + preview: _string(raw, 'preview'), + ); + case 'agentSpawn': + case 'agent_spawn': + final sessionId = _string(raw, 'sessionId', 'session_id'); + final runId = _string(raw, 'runId', 'run_id'); + if (sessionId == null || runId == null) return null; + return AgentSpawnContentBlock( + id: id, + sessionId: sessionId, + runId: runId, + pillId: _string(raw, 'pillId', 'pill_id'), + title: _string(raw, 'title') ?? '', + objective: _string(raw, 'objective') ?? '', + ); + case 'agentCompletion': + case 'agent_completion': + return AgentCompletionContentBlock( + id: id, + sessionId: _string(raw, 'sessionId', 'session_id'), + runId: _string(raw, 'runId', 'run_id'), + pillId: _string(raw, 'pillId', 'pill_id'), + title: _string(raw, 'title') ?? '', + output: _string(raw, 'output') ?? '', + status: _string(raw, 'status') ?? 'completed', + ); + default: + return UnknownContentBlock(id: id, type: type, raw: Map.unmodifiable(raw)); + } + } + + static ChatContentBlock? _decodeQuestionCard(Map raw, String id) { + final questionId = _string(raw, 'questionId', 'question_id'); + final text = _string(raw, 'text'); + final subject = raw['subject']; + if (questionId == null || text == null || subject is! Map) return null; + final subjectMap = Map.from(subject); + final subjectKind = _string(subjectMap, 'kind'); + final subjectId = _string(subjectMap, 'id'); + if (subjectKind == null || subjectId == null) return null; + + final rawOptions = raw['options']; + if (rawOptions is! List) return null; + final options = []; + for (final entry in rawOptions) { + if (entry is! Map) continue; + final option = Map.from(entry); + final optionId = _string(option, 'optionId', 'option_id'); + final label = _string(option, 'label'); + if (optionId == null || label == null) continue; + options.add( + QuestionCardOption( + optionId: optionId, + label: label, + preparedAnswer: _string(option, 'preparedAnswer', 'prepared_answer') ?? label, + isDeferral: option['defer'] == true, + ), + ); + } + if (options.isEmpty) return null; + + return QuestionCardContentBlock( + id: id, + questionId: questionId, + text: text, + subjectKind: subjectKind, + subjectId: subjectId, + options: List.unmodifiable(options), + selectedOptionId: _string(raw, 'selectedOptionId', 'selected_option_id'), + ); + } + + static List _decodeRecommendedActionItems(Object? value) { + if (value is! List) return const []; + final items = []; + for (final entry in value) { + if (entry is! Map) continue; + final item = Map.from(entry); + final description = _string(item, 'description'); + if (description == null) continue; + items.add( + ConversationLinkActionItem( + description: description, + taskId: _string(item, 'taskId', 'task_id'), + ), + ); + } + return List.unmodifiable(items); + } + + static String? _string(Map raw, String camel, [String? snake]) { + final value = raw[camel] ?? (snake == null ? null : raw[snake]); + if (value is! String) return null; + return value.trim().isEmpty ? null : value; + } + + static int? _int(Map raw, String camel, [String? snake]) { + final value = raw[camel] ?? (snake == null ? null : raw[snake]); + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} + +class TextContentBlock extends ChatContentBlock { + const TextContentBlock({required super.id, required this.text}); + + final String text; + + @override + String get type => 'text'; +} + +class ToolCallContentBlock extends ChatContentBlock { + const ToolCallContentBlock({ + required super.id, + required this.name, + required this.status, + this.toolUseId, + this.inputSummary, + this.inputDetails, + this.output, + }); + + final String name; + final String status; + final String? toolUseId; + final String? inputSummary; + final String? inputDetails; + final String? output; + + @override + String get type => 'toolCall'; +} + +class ThinkingContentBlock extends ChatContentBlock { + const ThinkingContentBlock({required super.id, required this.text}); + + final String text; + + @override + String get type => 'thinking'; +} + +class DiscoveryCardContentBlock extends ChatContentBlock { + const DiscoveryCardContentBlock({ + required super.id, + required this.title, + required this.summary, + required this.fullText, + }); + + final String title; + final String summary; + final String fullText; + + @override + String get type => 'discoveryCard'; +} + +class QuestionCardOption { + const QuestionCardOption({ + required this.optionId, + required this.label, + required this.preparedAnswer, + this.isDeferral = false, + }); + + final String optionId; + final String label; + final String preparedAnswer; + final bool isDeferral; +} + +class QuestionCardContentBlock extends ChatContentBlock { + const QuestionCardContentBlock({ + required super.id, + required this.questionId, + required this.text, + required this.subjectKind, + required this.subjectId, + required this.options, + this.selectedOptionId, + }); + + final String questionId; + final String text; + final String subjectKind; + final String subjectId; + final List options; + final String? selectedOptionId; + + @override + String get type => 'questionCard'; +} + +class TaskCardContentBlock extends ChatContentBlock { + const TaskCardContentBlock({required super.id, required this.taskId}); + + final String taskId; + + @override + String get type => 'taskCard'; +} + +class GoalLinkContentBlock extends ChatContentBlock { + const GoalLinkContentBlock({required super.id, required this.goalId, required this.summary}); + + final String goalId; + final String summary; + + @override + String get type => 'goalLink'; +} + +class CaptureLinkContentBlock extends ChatContentBlock { + const CaptureLinkContentBlock({ + required super.id, + required this.conversationId, + required this.summary, + this.momentTimestampMs, + }); + + final String conversationId; + final String summary; + final int? momentTimestampMs; + + @override + String get type => 'captureLink'; +} + +class ConversationLinkActionItem { + const ConversationLinkActionItem({required this.description, this.taskId}); + + final String description; + final String? taskId; +} + +class ConversationLinkContentBlock extends ChatContentBlock { + const ConversationLinkContentBlock({ + required super.id, + required this.conversationId, + required this.summary, + this.recommendedActionItems = const [], + }); + + final String conversationId; + final String summary; + final List recommendedActionItems; + + @override + String get type => 'conversationLink'; +} + +class MemoryLinkContentBlock extends ChatContentBlock { + const MemoryLinkContentBlock({required super.id, required this.memoryId, required this.summary}); + + final String memoryId; + final String summary; + + @override + String get type => 'memoryLink'; +} + +class CitationContentBlock extends ChatContentBlock { + const CitationContentBlock({ + required super.id, + required this.ordinal, + required this.kind, + required this.sourceId, + this.title, + this.preview, + }); + + final int ordinal; + final String kind; + final String sourceId; + final String? title; + final String? preview; + + @override + String get type => 'citation'; +} + +class AgentSpawnContentBlock extends ChatContentBlock { + const AgentSpawnContentBlock({ + required super.id, + required this.sessionId, + required this.runId, + this.pillId, + this.title = '', + this.objective = '', + }); + + final String sessionId; + final String runId; + final String? pillId; + final String title; + final String objective; + + @override + String get type => 'agentSpawn'; +} + +class AgentCompletionContentBlock extends ChatContentBlock { + const AgentCompletionContentBlock({ + required super.id, + this.sessionId, + this.runId, + this.pillId, + this.title = '', + this.output = '', + this.status = 'completed', + }); + + final String? sessionId; + final String? runId; + final String? pillId; + final String title; + final String output; + final String status; + + @override + String get type => 'agentCompletion'; +} + +/// A block type this client does not know. Kept so the message keeps rendering +/// its synthesized fallback text instead of silently losing content. +class UnknownContentBlock extends ChatContentBlock { + const UnknownContentBlock({required super.id, required String type, required this.raw}) : _type = type; + + final String _type; + final Map raw; + + @override + String get type => _type; +} diff --git a/app/lib/backend/schema/message.dart b/app/lib/backend/schema/message.dart index dbdab61e2c0..e4f2ca9e18f 100644 --- a/app/lib/backend/schema/message.dart +++ b/app/lib/backend/schema/message.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; import 'package:omi/backend/schema/gen/messages_wire.g.dart' as wire; import 'package:omi/backend/schema/memory_review.dart'; import 'package:omi/models/chat_evidence_reference.dart'; @@ -444,6 +445,7 @@ class ServerMessage { return value.whereType().map((item) => Map.from(item)).toList(growable: false); } + List? _typedContentBlocks; static Object? _tryDecodeJson(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) return null; @@ -475,38 +477,23 @@ class ServerMessage { static const _followUpTypes = {'followUp', 'follow_up'}; - static const _desktopChatChromeTypes = { - 'goalLink', - 'goal_link', - 'taskCard', - 'task_card', - 'questionCard', - 'question_card', - }; - - /// Desktop Chat-first cards (goal/task/question) are interactive shell chrome. - /// Mobile has no renderer for them, so fallback dumps like - /// `Goal - … Task Task Task` should not appear in the phone timeline. - bool get hideFromMobileChat { - if (sender != MessageSender.ai) return false; - if (type != MessageType.text) return false; - if (files.isNotEmpty || memories.isNotEmpty) return false; + /// Typed projection of [contentBlocks], decoded once per message. + /// + /// The raw list stays authoritative on the wire (see [toJson]); this is the + /// renderable view used by the chat content-block widgets. + List get typedContentBlocks => _typedContentBlocks ??= ChatContentBlock.decodeList(contentBlocks); + + /// True when [text] carries nothing beyond the fallback text synthesized from + /// [contentBlocks]. The interactive blocks then replace the body instead of + /// repeating it. + bool get textIsStructuredFallback { if (contentBlocks.isEmpty) return false; - if (!_blocksAreDesktopChatChromeOnly(contentBlocks)) return false; final fallback = _structuredFallbackText(contentBlocks); if (fallback.isEmpty) return false; final body = text.trim(); return body.isEmpty || _normalizeWhitespace(body) == _normalizeWhitespace(fallback); } - static List visibleOnMobile(Iterable messages) { - return messages.where((message) => !message.hideFromMobileChat).toList(); - } - - static bool _blocksAreDesktopChatChromeOnly(List> blocks) { - return blocks.every((block) => _desktopChatChromeTypes.contains(block['type'])); - } - static String _normalizeWhitespace(String value) { return value.split(RegExp(r'\s+')).where((part) => part.isNotEmpty).join(' '); } diff --git a/app/lib/l10n/app_ar.arb b/app/lib/l10n/app_ar.arb index c9a0e09ad1d..87213d54f56 100644 --- a/app/lib/l10n/app_ar.arb +++ b/app/lib/l10n/app_ar.arb @@ -3211,6 +3211,16 @@ "pendantFullSyncBlocked": "ذاكرة Pendant ممتلئة وما زال في وضع التسجيل، لذا لا يمكن نقل الصوت المخزّن. اضغط على زر Pendant لإيقاف التسجيل، ثم أعد المزامنة.", "conversationsNotCapturedCount": "لم يتم التسجيل ({count})", "transcriptionNoAudio": "النسخ لا يستلم الصوت", + "chatBlockTask": "مهمة", + "chatBlockGoal": "هدف", + "chatBlockConversation": "محادثة", + "chatBlockMemory": "ذكرى", + "chatBlockQuestion": "سؤال", + "chatBlockOpenInGoals": "فتح في الأهداف", + "chatBlockOpenConversation": "فتح المحادثة", + "chatBlockOpenInMemories": "فتح في الذكريات", + "chatBlockUnavailable": "لم يعد متاحًا", + "chatBlockRecommendedNextSteps": "الخطوات التالية الموصى بها", "couldNotLoadMemories": "تعذر تحميل الذكريات", "couldNotLoadKnowledgeGraph": "تعذر تحميل الرسم البياني للمعرفة" } diff --git a/app/lib/l10n/app_be.arb b/app/lib/l10n/app_be.arb index 53f70c636a4..34bc5e63f3a 100644 --- a/app/lib/l10n/app_be.arb +++ b/app/lib/l10n/app_be.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Памяць Pendant запоўнена, і ён усё яшчэ ў рэжыме запісу, таму захаванае аўдыя нельга перадаць. Націсніце кнопку Pendant, каб спыніць запіс, а затым сінхранізуйце зноў.", "conversationsNotCapturedCount": "Не запісана ({count})", "transcriptionNoAudio": "Транскрыпцыя не атрымлівае аўдыё", + "chatBlockTask": "Задача", + "chatBlockGoal": "Мэта", + "chatBlockConversation": "Размова", + "chatBlockMemory": "Успамін", + "chatBlockQuestion": "Пытанне", + "chatBlockOpenInGoals": "Адкрыць у мэтах", + "chatBlockOpenConversation": "Адкрыць размову", + "chatBlockOpenInMemories": "Адкрыць ва ўспамінах", + "chatBlockUnavailable": "Больш недаступна", + "chatBlockRecommendedNextSteps": "Рэкамендаваныя наступныя крокі", "couldNotLoadMemories": "Не ўдалося загрузіць успаміны", "couldNotLoadKnowledgeGraph": "Не ўдалося загрузіць граф ведаў" } diff --git a/app/lib/l10n/app_bg.arb b/app/lib/l10n/app_bg.arb index 9e77bba6c5d..449a834269b 100644 --- a/app/lib/l10n/app_bg.arb +++ b/app/lib/l10n/app_bg.arb @@ -3213,6 +3213,16 @@ "pendantFullSyncBlocked": "Паметта на Pendant е пълна и той все още е в режим на запис, затова съхраненото аудио не може да бъде прехвърлено. Натиснете бутона на Pendant, за да спрете записа, и след това синхронизирайте отново.", "conversationsNotCapturedCount": "Не е записано ({count})", "transcriptionNoAudio": "Транскрипцията не получава аудио", + "chatBlockTask": "Задача", + "chatBlockGoal": "Цел", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Спомен", + "chatBlockQuestion": "Въпрос", + "chatBlockOpenInGoals": "Отваряне в „Цели“", + "chatBlockOpenConversation": "Отваряне на разговора", + "chatBlockOpenInMemories": "Отваряне в „Спомени“", + "chatBlockUnavailable": "Вече не е налично", + "chatBlockRecommendedNextSteps": "Препоръчани следващи стъпки", "couldNotLoadMemories": "Неуспешно зареждане на спомените", "couldNotLoadKnowledgeGraph": "Неуспешно зареждане на графа на знанията" } diff --git a/app/lib/l10n/app_bn.arb b/app/lib/l10n/app_bn.arb index 66b51548fa8..fd19739b588 100644 --- a/app/lib/l10n/app_bn.arb +++ b/app/lib/l10n/app_bn.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pendant-এর স্টোরেজ পূর্ণ এবং এটি এখনও রেকর্ডিং মোডে আছে, তাই সংরক্ষিত অডিও স্থানান্তর করা যাচ্ছে না। রেকর্ডিং বন্ধ করতে Pendant-এর বোতাম টিপুন, তারপর আবার সিঙ্ক করুন।", "conversationsNotCapturedCount": "রেকর্ড করা হয়নি ({count})", "transcriptionNoAudio": "ট্রান্সক্রিপশন অডিও গ্রহণ করছে না", + "chatBlockTask": "কাজ", + "chatBlockGoal": "লক্ষ্য", + "chatBlockConversation": "কথোপকথন", + "chatBlockMemory": "স্মৃতি", + "chatBlockQuestion": "প্রশ্ন", + "chatBlockOpenInGoals": "লক্ষ্যে খুলুন", + "chatBlockOpenConversation": "কথোপকথন খুলুন", + "chatBlockOpenInMemories": "স্মৃতিতে খুলুন", + "chatBlockUnavailable": "আর উপলব্ধ নেই", + "chatBlockRecommendedNextSteps": "প্রস্তাবিত পরবর্তী পদক্ষেপ", "couldNotLoadMemories": "স্মৃতি লোড করা যায়নি", "couldNotLoadKnowledgeGraph": "নলেজ গ্রাফ লোড করা যায়নি" } diff --git a/app/lib/l10n/app_bs.arb b/app/lib/l10n/app_bs.arb index 7b1872c9e2c..6b4d65ab4ad 100644 --- a/app/lib/l10n/app_bs.arb +++ b/app/lib/l10n/app_bs.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Memorija Pendanta je puna i još uvijek je u režimu snimanja, pa se pohranjeni zvuk ne može prenijeti. Pritisnite dugme na Pendantu da zaustavite snimanje, a zatim ponovo sinhronizujte.", "conversationsNotCapturedCount": "Nije snimljeno ({count})", "transcriptionNoAudio": "Transkripcija ne prima audio", + "chatBlockTask": "Zadatak", + "chatBlockGoal": "Cilj", + "chatBlockConversation": "Razgovor", + "chatBlockMemory": "Sjećanje", + "chatBlockQuestion": "Pitanje", + "chatBlockOpenInGoals": "Otvori u Ciljevima", + "chatBlockOpenConversation": "Otvori razgovor", + "chatBlockOpenInMemories": "Otvori u Sjećanjima", + "chatBlockUnavailable": "Više nije dostupno", + "chatBlockRecommendedNextSteps": "Preporučeni sljedeći koraci", "couldNotLoadMemories": "Nije moguće učitati uspomene", "couldNotLoadKnowledgeGraph": "Nije moguće učitati graf znanja" } diff --git a/app/lib/l10n/app_ca.arb b/app/lib/l10n/app_ca.arb index b436b515c0e..29ef6b034cf 100644 --- a/app/lib/l10n/app_ca.arb +++ b/app/lib/l10n/app_ca.arb @@ -3213,6 +3213,16 @@ "pendantFullSyncBlocked": "L'emmagatzematge del Pendant és ple i encara està en mode de gravació, així que l'àudio desat no es pot transferir. Prem el botó del Pendant per aturar la gravació i torna a sincronitzar.", "conversationsNotCapturedCount": "No enregistrat ({count})", "transcriptionNoAudio": "La transcripció no rep àudio", + "chatBlockTask": "Tasca", + "chatBlockGoal": "Objectiu", + "chatBlockConversation": "Conversa", + "chatBlockMemory": "Record", + "chatBlockQuestion": "Pregunta", + "chatBlockOpenInGoals": "Obre a Objectius", + "chatBlockOpenConversation": "Obre la conversa", + "chatBlockOpenInMemories": "Obre a Records", + "chatBlockUnavailable": "Ja no està disponible", + "chatBlockRecommendedNextSteps": "Propers passos recomanats", "couldNotLoadMemories": "No s'han pogut carregar els records", "couldNotLoadKnowledgeGraph": "No s'ha pogut carregar el graf de coneixement" } diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index 69899615a19..31f24643469 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -3213,6 +3213,16 @@ "pendantFullSyncBlocked": "Úložiště Pendantu je plné a stále je v režimu nahrávání, takže uložený zvuk nelze přenést. Stisknutím tlačítka na Pendantu zastavte nahrávání a poté znovu synchronizujte.", "conversationsNotCapturedCount": "Nezaznamenáno ({count})", "transcriptionNoAudio": "Transkripce nepřijímá zvuk", + "chatBlockTask": "Úkol", + "chatBlockGoal": "Cíl", + "chatBlockConversation": "Konverzace", + "chatBlockMemory": "Vzpomínka", + "chatBlockQuestion": "Otázka", + "chatBlockOpenInGoals": "Otevřít v Cílech", + "chatBlockOpenConversation": "Otevřít konverzaci", + "chatBlockOpenInMemories": "Otevřít ve Vzpomínkách", + "chatBlockUnavailable": "Již není k dispozici", + "chatBlockRecommendedNextSteps": "Doporučené další kroky", "couldNotLoadMemories": "Nepodařilo se načíst vzpomínky", "couldNotLoadKnowledgeGraph": "Nepodařilo se načíst graf znalostí" } diff --git a/app/lib/l10n/app_da.arb b/app/lib/l10n/app_da.arb index 992703c7335..fe305998828 100644 --- a/app/lib/l10n/app_da.arb +++ b/app/lib/l10n/app_da.arb @@ -3253,6 +3253,16 @@ "pendantFullSyncBlocked": "Din Pendants lager er fuldt, og den er stadig i optagetilstand, så den gemte lyd kan ikke overføres. Tryk på Pendantens knap for at stoppe optagelsen, og synkroniser derefter igen.", "conversationsNotCapturedCount": "Ikke optaget ({count})", "transcriptionNoAudio": "Transskription modtager ikke lyd", + "chatBlockTask": "Opgave", + "chatBlockGoal": "Mål", + "chatBlockConversation": "Samtale", + "chatBlockMemory": "Minde", + "chatBlockQuestion": "Spørgsmål", + "chatBlockOpenInGoals": "Åbn i Mål", + "chatBlockOpenConversation": "Åbn samtale", + "chatBlockOpenInMemories": "Åbn i Minder", + "chatBlockUnavailable": "Ikke længere tilgængelig", + "chatBlockRecommendedNextSteps": "Anbefalede næste trin", "couldNotLoadMemories": "Kunne ikke indlæse minder", "couldNotLoadKnowledgeGraph": "Kunne ikke indlæse vidensgrafen" } diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 7aaeaae0f01..2ef4cdb75f0 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -3212,6 +3212,16 @@ "pendantFullSyncBlocked": "Der Speicher deines Pendants ist voll und es befindet sich noch im Aufnahmemodus, daher kann das gespeicherte Audio nicht übertragen werden. Drücke die Taste am Pendant, um die Aufnahme zu stoppen, und synchronisiere dann erneut.", "conversationsNotCapturedCount": "Nicht erfasst ({count})", "transcriptionNoAudio": "Transkription empfängt kein Audio", + "chatBlockTask": "Aufgabe", + "chatBlockGoal": "Ziel", + "chatBlockConversation": "Gespräch", + "chatBlockMemory": "Erinnerung", + "chatBlockQuestion": "Frage", + "chatBlockOpenInGoals": "In Zielen öffnen", + "chatBlockOpenConversation": "Gespräch öffnen", + "chatBlockOpenInMemories": "In Erinnerungen öffnen", + "chatBlockUnavailable": "Nicht mehr verfügbar", + "chatBlockRecommendedNextSteps": "Empfohlene nächste Schritte", "couldNotLoadMemories": "Erinnerungen konnten nicht geladen werden", "couldNotLoadKnowledgeGraph": "Wissensgraph konnte nicht geladen werden" } diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index 77661e7a7a9..f69f22d6633 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Ο αποθηκευτικός χώρος του Pendant είναι πλήρης και βρίσκεται ακόμα σε λειτουργία εγγραφής, οπότε ο αποθηκευμένος ήχος δεν μπορεί να μεταφερθεί. Πατήστε το κουμπί του Pendant για να σταματήσετε την εγγραφή και μετά συγχρονίστε ξανά.", "conversationsNotCapturedCount": "Δεν καταγράφηκε ({count})", "transcriptionNoAudio": "Η μεταγραφή δεν λαμβάνει ήχο", + "chatBlockTask": "Εργασία", + "chatBlockGoal": "Στόχος", + "chatBlockConversation": "Συνομιλία", + "chatBlockMemory": "Ανάμνηση", + "chatBlockQuestion": "Ερώτηση", + "chatBlockOpenInGoals": "Άνοιγμα στους Στόχους", + "chatBlockOpenConversation": "Άνοιγμα συνομιλίας", + "chatBlockOpenInMemories": "Άνοιγμα στις Αναμνήσεις", + "chatBlockUnavailable": "Δεν είναι πλέον διαθέσιμο", + "chatBlockRecommendedNextSteps": "Προτεινόμενα επόμενα βήματα", "couldNotLoadMemories": "Δεν ήταν δυνατή η φόρτωση των αναμνήσεων", "couldNotLoadKnowledgeGraph": "Δεν ήταν δυνατή η φόρτωση του γραφήματος γνώσης" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index a02628338a4..eae5dec5b4a 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -11745,6 +11745,46 @@ "@tapPlusToStartRecording": { "description": "Empty-home hint pointing at the + record button" }, + "chatBlockTask": "Task", + "@chatBlockTask": { + "description": "Eyebrow label on a chat task card block" + }, + "chatBlockGoal": "Goal", + "@chatBlockGoal": { + "description": "Eyebrow label on a chat goal link block" + }, + "chatBlockConversation": "Conversation", + "@chatBlockConversation": { + "description": "Eyebrow label on a chat conversation/capture link block" + }, + "chatBlockMemory": "Memory", + "@chatBlockMemory": { + "description": "Eyebrow label on a chat memory link block" + }, + "chatBlockQuestion": "Question", + "@chatBlockQuestion": { + "description": "Eyebrow label on a chat question card block" + }, + "chatBlockOpenInGoals": "Open in Goals", + "@chatBlockOpenInGoals": { + "description": "Action on a chat goal link block" + }, + "chatBlockOpenConversation": "Open conversation", + "@chatBlockOpenConversation": { + "description": "Action on a chat conversation link block" + }, + "chatBlockOpenInMemories": "Open in Memories", + "@chatBlockOpenInMemories": { + "description": "Action on a chat memory link block" + }, + "chatBlockUnavailable": "No longer available", + "@chatBlockUnavailable": { + "description": "Status shown when a chat block's entity no longer exists" + }, + "chatBlockRecommendedNextSteps": "Recommended next steps", + "@chatBlockRecommendedNextSteps": { + "description": "Header above recommended action items on a chat conversation link block" + }, "couldNotLoadMemories": "Couldn't load memories", "couldNotLoadKnowledgeGraph": "Couldn't load knowledge graph", "@couldNotLoadMemories": { diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index fb85e692666..4f2e642ac61 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -3236,6 +3236,16 @@ "pendantFullSyncBlocked": "El almacenamiento de tu Pendant está lleno y sigue en modo de grabación, por lo que su audio almacenado no se puede transferir. Pulsa el botón del Pendant para detener la grabación y vuelve a sincronizar.", "conversationsNotCapturedCount": "No capturado ({count})", "transcriptionNoAudio": "La transcripción no recibe audio", + "chatBlockTask": "Tarea", + "chatBlockGoal": "Objetivo", + "chatBlockConversation": "Conversación", + "chatBlockMemory": "Recuerdo", + "chatBlockQuestion": "Pregunta", + "chatBlockOpenInGoals": "Abrir en Objetivos", + "chatBlockOpenConversation": "Abrir conversación", + "chatBlockOpenInMemories": "Abrir en Recuerdos", + "chatBlockUnavailable": "Ya no está disponible", + "chatBlockRecommendedNextSteps": "Próximos pasos recomendados", "couldNotLoadMemories": "No se pudieron cargar los recuerdos", "couldNotLoadKnowledgeGraph": "No se pudo cargar el grafo de conocimiento" } diff --git a/app/lib/l10n/app_et.arb b/app/lib/l10n/app_et.arb index 0d9213c173a..52e65ff25b8 100644 --- a/app/lib/l10n/app_et.arb +++ b/app/lib/l10n/app_et.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Pendanti mälu on täis ja see on endiselt salvestusrežiimis, seega salvestatud heli ei saa üle kanda. Salvestamise peatamiseks vajuta Pendanti nuppu ja seejärel sünkrooni uuesti.", "conversationsNotCapturedCount": "Salvestamata ({count})", "transcriptionNoAudio": "Transkriptsioon ei saa heli", + "chatBlockTask": "Ülesanne", + "chatBlockGoal": "Eesmärk", + "chatBlockConversation": "Vestlus", + "chatBlockMemory": "Mälestus", + "chatBlockQuestion": "Küsimus", + "chatBlockOpenInGoals": "Ava eesmärkides", + "chatBlockOpenConversation": "Ava vestlus", + "chatBlockOpenInMemories": "Ava mälestustes", + "chatBlockUnavailable": "Pole enam saadaval", + "chatBlockRecommendedNextSteps": "Soovitatud järgmised sammud", "couldNotLoadMemories": "Mälestusi ei õnnestunud laadida", "couldNotLoadKnowledgeGraph": "Teadmiste graafi ei õnnestunud laadida" } diff --git a/app/lib/l10n/app_fa.arb b/app/lib/l10n/app_fa.arb index 5c30f213048..45b10af7844 100644 --- a/app/lib/l10n/app_fa.arb +++ b/app/lib/l10n/app_fa.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "حافظه Pendant پر است و همچنان در حالت ضبط قرار دارد، بنابراین صدای ذخیره‌شده قابل انتقال نیست. دکمه Pendant را فشار دهید تا ضبط متوقف شود، سپس دوباره همگام‌سازی کنید.", "conversationsNotCapturedCount": "ثبت نشده ({count})", "transcriptionNoAudio": "رونویسی صدا دریافت نمی‌کند", + "chatBlockTask": "وظیفه", + "chatBlockGoal": "هدف", + "chatBlockConversation": "گفتگو", + "chatBlockMemory": "خاطره", + "chatBlockQuestion": "پرسش", + "chatBlockOpenInGoals": "باز کردن در اهداف", + "chatBlockOpenConversation": "باز کردن گفتگو", + "chatBlockOpenInMemories": "باز کردن در خاطرات", + "chatBlockUnavailable": "دیگر در دسترس نیست", + "chatBlockRecommendedNextSteps": "گام‌های بعدی پیشنهادی", "couldNotLoadMemories": "بارگذاری خاطرات ممکن نشد", "couldNotLoadKnowledgeGraph": "بارگذاری گراف دانش ممکن نشد" } diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index 9bd13325bd6..23a93fbaaaf 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Pendantin muisti on täynnä ja se on yhä äänitystilassa, joten tallennettua ääntä ei voi siirtää. Pysäytä äänitys painamalla Pendantin painiketta ja synkronoi sitten uudelleen.", "conversationsNotCapturedCount": "Ei tallennettu ({count})", "transcriptionNoAudio": "Transkriptio ei vastaanota ääntä", + "chatBlockTask": "Tehtävä", + "chatBlockGoal": "Tavoite", + "chatBlockConversation": "Keskustelu", + "chatBlockMemory": "Muisto", + "chatBlockQuestion": "Kysymys", + "chatBlockOpenInGoals": "Avaa Tavoitteissa", + "chatBlockOpenConversation": "Avaa keskustelu", + "chatBlockOpenInMemories": "Avaa Muistoissa", + "chatBlockUnavailable": "Ei ole enää saatavilla", + "chatBlockRecommendedNextSteps": "Suositellut seuraavat vaiheet", "couldNotLoadMemories": "Muistoja ei voitu ladata", "couldNotLoadKnowledgeGraph": "Tietograafia ei voitu ladata" } diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index 22be8fed42b..a73be2a7ba1 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -3270,6 +3270,16 @@ "pendantFullSyncBlocked": "Le stockage de votre Pendant est plein et il est encore en mode enregistrement, son audio stocké ne peut donc pas être transféré. Appuyez sur le bouton du Pendant pour arrêter l'enregistrement, puis synchronisez à nouveau.", "conversationsNotCapturedCount": "Non capturé ({count})", "transcriptionNoAudio": "La transcription ne reçoit pas d'audio", + "chatBlockTask": "Tâche", + "chatBlockGoal": "Objectif", + "chatBlockConversation": "Conversation", + "chatBlockMemory": "Souvenir", + "chatBlockQuestion": "Question", + "chatBlockOpenInGoals": "Ouvrir dans Objectifs", + "chatBlockOpenConversation": "Ouvrir la conversation", + "chatBlockOpenInMemories": "Ouvrir dans Souvenirs", + "chatBlockUnavailable": "N’est plus disponible", + "chatBlockRecommendedNextSteps": "Prochaines étapes recommandées", "couldNotLoadMemories": "Impossible de charger les souvenirs", "couldNotLoadKnowledgeGraph": "Impossible de charger le graphe de connaissances" } diff --git a/app/lib/l10n/app_he.arb b/app/lib/l10n/app_he.arb index 15c20dc820e..b5441bdd7ae 100644 --- a/app/lib/l10n/app_he.arb +++ b/app/lib/l10n/app_he.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "האחסון של ה-Pendant מלא והוא עדיין במצב הקלטה, ולכן לא ניתן להעביר את השמע השמור. לחצו על כפתור ה-Pendant כדי לעצור את ההקלטה, ולאחר מכן סנכרנו שוב.", "conversationsNotCapturedCount": "לא הוקלט ({count})", "transcriptionNoAudio": "התמליל אינו מקבל שמע", + "chatBlockTask": "משימה", + "chatBlockGoal": "יעד", + "chatBlockConversation": "שיחה", + "chatBlockMemory": "זיכרון", + "chatBlockQuestion": "שאלה", + "chatBlockOpenInGoals": "פתיחה ביעדים", + "chatBlockOpenConversation": "פתיחת השיחה", + "chatBlockOpenInMemories": "פתיחה בזיכרונות", + "chatBlockUnavailable": "אינו זמין עוד", + "chatBlockRecommendedNextSteps": "השלבים הבאים המומלצים", "couldNotLoadMemories": "לא ניתן לטעון את הזיכרונות", "couldNotLoadKnowledgeGraph": "לא ניתן לטעון את גרף הידע" } diff --git a/app/lib/l10n/app_hi.arb b/app/lib/l10n/app_hi.arb index 0f9cc656f8d..513d1785345 100644 --- a/app/lib/l10n/app_hi.arb +++ b/app/lib/l10n/app_hi.arb @@ -3236,6 +3236,16 @@ "pendantFullSyncBlocked": "Pendant का स्टोरेज भर गया है और यह अभी भी रिकॉर्डिंग मोड में है, इसलिए संग्रहीत ऑडियो स्थानांतरित नहीं किया जा सकता। रिकॉर्डिंग रोकने के लिए Pendant का बटन दबाएँ, फिर दोबारा सिंक करें।", "conversationsNotCapturedCount": "रिकॉर्ड नहीं हुआ ({count})", "transcriptionNoAudio": "ट्रांसक्रिप्शन ऑडियो प्राप्त नहीं कर रहा है", + "chatBlockTask": "कार्य", + "chatBlockGoal": "लक्ष्य", + "chatBlockConversation": "बातचीत", + "chatBlockMemory": "स्मृति", + "chatBlockQuestion": "प्रश्न", + "chatBlockOpenInGoals": "लक्ष्यों में खोलें", + "chatBlockOpenConversation": "बातचीत खोलें", + "chatBlockOpenInMemories": "स्मृतियों में खोलें", + "chatBlockUnavailable": "अब उपलब्ध नहीं है", + "chatBlockRecommendedNextSteps": "अनुशंसित अगले कदम", "couldNotLoadMemories": "यादें लोड नहीं हो सकीं", "couldNotLoadKnowledgeGraph": "नॉलेज ग्राफ़ लोड नहीं हो सका" } diff --git a/app/lib/l10n/app_hr.arb b/app/lib/l10n/app_hr.arb index 7595c32ce5e..3ffe76a8336 100644 --- a/app/lib/l10n/app_hr.arb +++ b/app/lib/l10n/app_hr.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pohrana Pendanta je puna i još je u načinu snimanja, pa se pohranjeni zvuk ne može prenijeti. Pritisnite gumb na Pendantu da zaustavite snimanje, a zatim ponovno sinkronizirajte.", "conversationsNotCapturedCount": "Nije snimljeno ({count})", "transcriptionNoAudio": "Transkripcija ne prima zvuk", + "chatBlockTask": "Zadatak", + "chatBlockGoal": "Cilj", + "chatBlockConversation": "Razgovor", + "chatBlockMemory": "Sjećanje", + "chatBlockQuestion": "Pitanje", + "chatBlockOpenInGoals": "Otvori u Ciljevima", + "chatBlockOpenConversation": "Otvori razgovor", + "chatBlockOpenInMemories": "Otvori u Sjećanjima", + "chatBlockUnavailable": "Više nije dostupno", + "chatBlockRecommendedNextSteps": "Preporučeni sljedeći koraci", "couldNotLoadMemories": "Nije moguće učitati uspomene", "couldNotLoadKnowledgeGraph": "Nije moguće učitati graf znanja" } diff --git a/app/lib/l10n/app_hu.arb b/app/lib/l10n/app_hu.arb index 6811c953e6b..e69de71d432 100644 --- a/app/lib/l10n/app_hu.arb +++ b/app/lib/l10n/app_hu.arb @@ -3331,6 +3331,16 @@ "pendantFullSyncBlocked": "A Pendant tárhelye megtelt, és még mindig felvételi módban van, ezért a tárolt hang nem vihető át. Nyomja meg a Pendant gombját a felvétel leállításához, majd szinkronizáljon újra.", "conversationsNotCapturedCount": "Nincs rögzítve ({count})", "transcriptionNoAudio": "Az átírás nem kap hangot", + "chatBlockTask": "Feladat", + "chatBlockGoal": "Cél", + "chatBlockConversation": "Beszélgetés", + "chatBlockMemory": "Emlék", + "chatBlockQuestion": "Kérdés", + "chatBlockOpenInGoals": "Megnyitás a Célokban", + "chatBlockOpenConversation": "Beszélgetés megnyitása", + "chatBlockOpenInMemories": "Megnyitás az Emlékekben", + "chatBlockUnavailable": "Már nem érhető el", + "chatBlockRecommendedNextSteps": "Javasolt következő lépések", "couldNotLoadMemories": "Nem sikerült betölteni az emlékeket", "couldNotLoadKnowledgeGraph": "Nem sikerült betölteni a tudásgráfot" } diff --git a/app/lib/l10n/app_id.arb b/app/lib/l10n/app_id.arb index 117f49d7a11..b055f6469ae 100644 --- a/app/lib/l10n/app_id.arb +++ b/app/lib/l10n/app_id.arb @@ -3277,6 +3277,16 @@ "pendantFullSyncBlocked": "Penyimpanan Pendant penuh dan masih dalam mode perekaman, sehingga audio yang tersimpan tidak dapat ditransfer. Tekan tombol Pendant untuk menghentikan perekaman, lalu sinkronkan lagi.", "conversationsNotCapturedCount": "Tidak direkam ({count})", "transcriptionNoAudio": "Transkripsi tidak menerima audio", + "chatBlockTask": "Tugas", + "chatBlockGoal": "Tujuan", + "chatBlockConversation": "Percakapan", + "chatBlockMemory": "Memori", + "chatBlockQuestion": "Pertanyaan", + "chatBlockOpenInGoals": "Buka di Tujuan", + "chatBlockOpenConversation": "Buka percakapan", + "chatBlockOpenInMemories": "Buka di Memori", + "chatBlockUnavailable": "Tidak lagi tersedia", + "chatBlockRecommendedNextSteps": "Langkah berikutnya yang disarankan", "couldNotLoadMemories": "Tidak dapat memuat kenangan", "couldNotLoadKnowledgeGraph": "Tidak dapat memuat graf pengetahuan" } diff --git a/app/lib/l10n/app_it.arb b/app/lib/l10n/app_it.arb index 346752660f2..a5fabb304ec 100644 --- a/app/lib/l10n/app_it.arb +++ b/app/lib/l10n/app_it.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "La memoria del Pendant è piena ed è ancora in modalità registrazione, quindi l'audio memorizzato non può essere trasferito. Premi il pulsante del Pendant per interrompere la registrazione, poi sincronizza di nuovo.", "conversationsNotCapturedCount": "Non registrato ({count})", "transcriptionNoAudio": "La trascrizione non riceve audio", + "chatBlockTask": "Attività", + "chatBlockGoal": "Obiettivo", + "chatBlockConversation": "Conversazione", + "chatBlockMemory": "Ricordo", + "chatBlockQuestion": "Domanda", + "chatBlockOpenInGoals": "Apri in Obiettivi", + "chatBlockOpenConversation": "Apri conversazione", + "chatBlockOpenInMemories": "Apri in Ricordi", + "chatBlockUnavailable": "Non è più disponibile", + "chatBlockRecommendedNextSteps": "Prossimi passi consigliati", "couldNotLoadMemories": "Impossibile caricare i ricordi", "couldNotLoadKnowledgeGraph": "Impossibile caricare il grafo della conoscenza" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 5f15c135a62..620d92a07c0 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -3211,6 +3211,16 @@ "pendantFullSyncBlocked": "Pendantのストレージが満杯で、まだ録音モードのままのため、保存された音声を転送できません。Pendantのボタンを押して録音を停止してから、もう一度同期してください。", "conversationsNotCapturedCount": "未記録 ({count})", "transcriptionNoAudio": "文字起こしが音声を受信していません", + "chatBlockTask": "タスク", + "chatBlockGoal": "目標", + "chatBlockConversation": "会話", + "chatBlockMemory": "メモリー", + "chatBlockQuestion": "質問", + "chatBlockOpenInGoals": "目標で開く", + "chatBlockOpenConversation": "会話を開く", + "chatBlockOpenInMemories": "メモリーで開く", + "chatBlockUnavailable": "現在は利用できません", + "chatBlockRecommendedNextSteps": "おすすめの次のステップ", "couldNotLoadMemories": "記憶を読み込めませんでした", "couldNotLoadKnowledgeGraph": "ナレッジグラフを読み込めませんでした" } diff --git a/app/lib/l10n/app_kn.arb b/app/lib/l10n/app_kn.arb index 0e65a047e3f..af218ff1f70 100644 --- a/app/lib/l10n/app_kn.arb +++ b/app/lib/l10n/app_kn.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pendant ನ ಸಂಗ್ರಹಣೆ ತುಂಬಿದೆ ಮತ್ತು ಅದು ಇನ್ನೂ ರೆಕಾರ್ಡಿಂಗ್ ಮೋಡ್‌ನಲ್ಲಿದೆ, ಆದ್ದರಿಂದ ಸಂಗ್ರಹಿಸಿದ ಆಡಿಯೊವನ್ನು ವರ್ಗಾಯಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ. ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಲು Pendant ನ ಬಟನ್ ಒತ್ತಿ, ನಂತರ ಮತ್ತೆ ಸಿಂಕ್ ಮಾಡಿ.", "conversationsNotCapturedCount": "ದಾಖಲಾಗಿಲ್ಲ ({count})", "transcriptionNoAudio": "ಲಿಪ್ಯಂತರಣ ಆಡಿಯೊ ಸ್ವೀಕರಿಸುತ್ತಿಲ್ಲ", + "chatBlockTask": "ಕಾರ್ಯ", + "chatBlockGoal": "ಗುರಿ", + "chatBlockConversation": "ಸಂಭಾಷಣೆ", + "chatBlockMemory": "ನೆನಪು", + "chatBlockQuestion": "ಪ್ರಶ್ನೆ", + "chatBlockOpenInGoals": "ಗುರಿಗಳಲ್ಲಿ ತೆರೆಯಿರಿ", + "chatBlockOpenConversation": "ಸಂಭಾಷಣೆ ತೆರೆಯಿರಿ", + "chatBlockOpenInMemories": "ನೆನಪುಗಳಲ್ಲಿ ತೆರೆಯಿರಿ", + "chatBlockUnavailable": "ಇನ್ನು ಲಭ್ಯವಿಲ್ಲ", + "chatBlockRecommendedNextSteps": "ಶಿಫಾರಸು ಮಾಡಿದ ಮುಂದಿನ ಹಂತಗಳು", "couldNotLoadMemories": "ನೆನಪುಗಳನ್ನು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ", "couldNotLoadKnowledgeGraph": "ಜ್ಞಾನ ಗ್ರಾಫ್ ಅನ್ನು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 64c1da7c06f..67c7621e903 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Pendant의 저장 공간이 가득 찼고 아직 녹음 모드이므로 저장된 오디오를 전송할 수 없습니다. Pendant의 버튼을 눌러 녹음을 중지한 다음 다시 동기화하세요.", "conversationsNotCapturedCount": "기록되지 않음 ({count})", "transcriptionNoAudio": "전사가 오디오를 받지 못하고 있습니다", + "chatBlockTask": "작업", + "chatBlockGoal": "목표", + "chatBlockConversation": "대화", + "chatBlockMemory": "메모리", + "chatBlockQuestion": "질문", + "chatBlockOpenInGoals": "목표에서 열기", + "chatBlockOpenConversation": "대화 열기", + "chatBlockOpenInMemories": "메모리에서 열기", + "chatBlockUnavailable": "더 이상 사용할 수 없음", + "chatBlockRecommendedNextSteps": "권장 다음 단계", "couldNotLoadMemories": "추억을 불러올 수 없습니다", "couldNotLoadKnowledgeGraph": "지식 그래프를 불러올 수 없습니다" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 0adb984c76d..205658f0651 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -18465,6 +18465,66 @@ abstract class AppLocalizations { /// **'Tap + to start recording'** String get tapPlusToStartRecording; + /// Eyebrow label on a chat task card block + /// + /// In en, this message translates to: + /// **'Task'** + String get chatBlockTask; + + /// Eyebrow label on a chat goal link block + /// + /// In en, this message translates to: + /// **'Goal'** + String get chatBlockGoal; + + /// Eyebrow label on a chat conversation/capture link block + /// + /// In en, this message translates to: + /// **'Conversation'** + String get chatBlockConversation; + + /// Eyebrow label on a chat memory link block + /// + /// In en, this message translates to: + /// **'Memory'** + String get chatBlockMemory; + + /// Eyebrow label on a chat question card block + /// + /// In en, this message translates to: + /// **'Question'** + String get chatBlockQuestion; + + /// Action on a chat goal link block + /// + /// In en, this message translates to: + /// **'Open in Goals'** + String get chatBlockOpenInGoals; + + /// Action on a chat conversation link block + /// + /// In en, this message translates to: + /// **'Open conversation'** + String get chatBlockOpenConversation; + + /// Action on a chat memory link block + /// + /// In en, this message translates to: + /// **'Open in Memories'** + String get chatBlockOpenInMemories; + + /// Status shown when a chat block's entity no longer exists + /// + /// In en, this message translates to: + /// **'No longer available'** + String get chatBlockUnavailable; + + /// Header above recommended action items on a chat conversation link block + /// + /// In en, this message translates to: + /// **'Recommended next steps'** + String get chatBlockRecommendedNextSteps; + /// Retryable error when fetching memories failed instead of returning an empty list /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index 0de111ead9d..7b2873958ed 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -9861,6 +9861,36 @@ class AppLocalizationsAr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'مهمة'; + + @override + String get chatBlockGoal => 'هدف'; + + @override + String get chatBlockConversation => 'محادثة'; + + @override + String get chatBlockMemory => 'ذكرى'; + + @override + String get chatBlockQuestion => 'سؤال'; + + @override + String get chatBlockOpenInGoals => 'فتح في الأهداف'; + + @override + String get chatBlockOpenConversation => 'فتح المحادثة'; + + @override + String get chatBlockOpenInMemories => 'فتح في الذكريات'; + + @override + String get chatBlockUnavailable => 'لم يعد متاحًا'; + + @override + String get chatBlockRecommendedNextSteps => 'الخطوات التالية الموصى بها'; + @override String get couldNotLoadMemories => 'تعذر تحميل الذكريات'; diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index 713cfdcdacf..9010dd641df 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -9951,6 +9951,36 @@ class AppLocalizationsBe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Мэта'; + + @override + String get chatBlockConversation => 'Размова'; + + @override + String get chatBlockMemory => 'Успамін'; + + @override + String get chatBlockQuestion => 'Пытанне'; + + @override + String get chatBlockOpenInGoals => 'Адкрыць у мэтах'; + + @override + String get chatBlockOpenConversation => 'Адкрыць размову'; + + @override + String get chatBlockOpenInMemories => 'Адкрыць ва ўспамінах'; + + @override + String get chatBlockUnavailable => 'Больш недаступна'; + + @override + String get chatBlockRecommendedNextSteps => 'Рэкамендаваныя наступныя крокі'; + @override String get couldNotLoadMemories => 'Не ўдалося загрузіць успаміны'; diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index 35af2f9476f..0c35a9cb8c8 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -9956,6 +9956,36 @@ class AppLocalizationsBg extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Цел'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Спомен'; + + @override + String get chatBlockQuestion => 'Въпрос'; + + @override + String get chatBlockOpenInGoals => 'Отваряне в „Цели“'; + + @override + String get chatBlockOpenConversation => 'Отваряне на разговора'; + + @override + String get chatBlockOpenInMemories => 'Отваряне в „Спомени“'; + + @override + String get chatBlockUnavailable => 'Вече не е налично'; + + @override + String get chatBlockRecommendedNextSteps => 'Препоръчани следващи стъпки'; + @override String get couldNotLoadMemories => 'Неуспешно зареждане на спомените'; diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index be794eda8d9..838dca862eb 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -9924,6 +9924,36 @@ class AppLocalizationsBn extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'কাজ'; + + @override + String get chatBlockGoal => 'লক্ষ্য'; + + @override + String get chatBlockConversation => 'কথোপকথন'; + + @override + String get chatBlockMemory => 'স্মৃতি'; + + @override + String get chatBlockQuestion => 'প্রশ্ন'; + + @override + String get chatBlockOpenInGoals => 'লক্ষ্যে খুলুন'; + + @override + String get chatBlockOpenConversation => 'কথোপকথন খুলুন'; + + @override + String get chatBlockOpenInMemories => 'স্মৃতিতে খুলুন'; + + @override + String get chatBlockUnavailable => 'আর উপলব্ধ নেই'; + + @override + String get chatBlockRecommendedNextSteps => 'প্রস্তাবিত পরবর্তী পদক্ষেপ'; + @override String get couldNotLoadMemories => 'স্মৃতি লোড করা যায়নি'; diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index 4e7b94d0b73..dcff97e3210 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -9948,6 +9948,36 @@ class AppLocalizationsBs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Zadatak'; + + @override + String get chatBlockGoal => 'Cilj'; + + @override + String get chatBlockConversation => 'Razgovor'; + + @override + String get chatBlockMemory => 'Sjećanje'; + + @override + String get chatBlockQuestion => 'Pitanje'; + + @override + String get chatBlockOpenInGoals => 'Otvori u Ciljevima'; + + @override + String get chatBlockOpenConversation => 'Otvori razgovor'; + + @override + String get chatBlockOpenInMemories => 'Otvori u Sjećanjima'; + + @override + String get chatBlockUnavailable => 'Više nije dostupno'; + + @override + String get chatBlockRecommendedNextSteps => 'Preporučeni sljedeći koraci'; + @override String get couldNotLoadMemories => 'Nije moguće učitati uspomene'; diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index e0b429afa38..567c0411dfd 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -9976,6 +9976,36 @@ class AppLocalizationsCa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tasca'; + + @override + String get chatBlockGoal => 'Objectiu'; + + @override + String get chatBlockConversation => 'Conversa'; + + @override + String get chatBlockMemory => 'Record'; + + @override + String get chatBlockQuestion => 'Pregunta'; + + @override + String get chatBlockOpenInGoals => 'Obre a Objectius'; + + @override + String get chatBlockOpenConversation => 'Obre la conversa'; + + @override + String get chatBlockOpenInMemories => 'Obre a Records'; + + @override + String get chatBlockUnavailable => 'Ja no està disponible'; + + @override + String get chatBlockRecommendedNextSteps => 'Propers passos recomanats'; + @override String get couldNotLoadMemories => 'No s\'han pogut carregar els records'; diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index 3584f1c743f..d3cbfc6b0ad 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -9920,6 +9920,36 @@ class AppLocalizationsCs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Úkol'; + + @override + String get chatBlockGoal => 'Cíl'; + + @override + String get chatBlockConversation => 'Konverzace'; + + @override + String get chatBlockMemory => 'Vzpomínka'; + + @override + String get chatBlockQuestion => 'Otázka'; + + @override + String get chatBlockOpenInGoals => 'Otevřít v Cílech'; + + @override + String get chatBlockOpenConversation => 'Otevřít konverzaci'; + + @override + String get chatBlockOpenInMemories => 'Otevřít ve Vzpomínkách'; + + @override + String get chatBlockUnavailable => 'Již není k dispozici'; + + @override + String get chatBlockRecommendedNextSteps => 'Doporučené další kroky'; + @override String get couldNotLoadMemories => 'Nepodařilo se načíst vzpomínky'; diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index 14d6dcc79c5..05ee70cc23c 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -9903,6 +9903,36 @@ class AppLocalizationsDa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Opgave'; + + @override + String get chatBlockGoal => 'Mål'; + + @override + String get chatBlockConversation => 'Samtale'; + + @override + String get chatBlockMemory => 'Minde'; + + @override + String get chatBlockQuestion => 'Spørgsmål'; + + @override + String get chatBlockOpenInGoals => 'Åbn i Mål'; + + @override + String get chatBlockOpenConversation => 'Åbn samtale'; + + @override + String get chatBlockOpenInMemories => 'Åbn i Minder'; + + @override + String get chatBlockUnavailable => 'Ikke længere tilgængelig'; + + @override + String get chatBlockRecommendedNextSteps => 'Anbefalede næste trin'; + @override String get couldNotLoadMemories => 'Kunne ikke indlæse minder'; diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 02d6ee5fb55..bb11fc3f482 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -10002,6 +10002,36 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Aufgabe'; + + @override + String get chatBlockGoal => 'Ziel'; + + @override + String get chatBlockConversation => 'Gespräch'; + + @override + String get chatBlockMemory => 'Erinnerung'; + + @override + String get chatBlockQuestion => 'Frage'; + + @override + String get chatBlockOpenInGoals => 'In Zielen öffnen'; + + @override + String get chatBlockOpenConversation => 'Gespräch öffnen'; + + @override + String get chatBlockOpenInMemories => 'In Erinnerungen öffnen'; + + @override + String get chatBlockUnavailable => 'Nicht mehr verfügbar'; + + @override + String get chatBlockRecommendedNextSteps => 'Empfohlene nächste Schritte'; + @override String get couldNotLoadMemories => 'Erinnerungen konnten nicht geladen werden'; diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index 4424a7a9936..f8f42a1c704 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -9989,6 +9989,36 @@ class AppLocalizationsEl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Εργασία'; + + @override + String get chatBlockGoal => 'Στόχος'; + + @override + String get chatBlockConversation => 'Συνομιλία'; + + @override + String get chatBlockMemory => 'Ανάμνηση'; + + @override + String get chatBlockQuestion => 'Ερώτηση'; + + @override + String get chatBlockOpenInGoals => 'Άνοιγμα στους Στόχους'; + + @override + String get chatBlockOpenConversation => 'Άνοιγμα συνομιλίας'; + + @override + String get chatBlockOpenInMemories => 'Άνοιγμα στις Αναμνήσεις'; + + @override + String get chatBlockUnavailable => 'Δεν είναι πλέον διαθέσιμο'; + + @override + String get chatBlockRecommendedNextSteps => 'Προτεινόμενα επόμενα βήματα'; + @override String get couldNotLoadMemories => 'Δεν ήταν δυνατή η φόρτωση των αναμνήσεων'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 5fc07cb1eb7..c503b189f59 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -9910,6 +9910,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Task'; + + @override + String get chatBlockGoal => 'Goal'; + + @override + String get chatBlockConversation => 'Conversation'; + + @override + String get chatBlockMemory => 'Memory'; + + @override + String get chatBlockQuestion => 'Question'; + + @override + String get chatBlockOpenInGoals => 'Open in Goals'; + + @override + String get chatBlockOpenConversation => 'Open conversation'; + + @override + String get chatBlockOpenInMemories => 'Open in Memories'; + + @override + String get chatBlockUnavailable => 'No longer available'; + + @override + String get chatBlockRecommendedNextSteps => 'Recommended next steps'; + @override String get couldNotLoadMemories => 'Couldn\'t load memories'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index f314a67d821..d6ce2c8b54d 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -9943,6 +9943,36 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tarea'; + + @override + String get chatBlockGoal => 'Objetivo'; + + @override + String get chatBlockConversation => 'Conversación'; + + @override + String get chatBlockMemory => 'Recuerdo'; + + @override + String get chatBlockQuestion => 'Pregunta'; + + @override + String get chatBlockOpenInGoals => 'Abrir en Objetivos'; + + @override + String get chatBlockOpenConversation => 'Abrir conversación'; + + @override + String get chatBlockOpenInMemories => 'Abrir en Recuerdos'; + + @override + String get chatBlockUnavailable => 'Ya no está disponible'; + + @override + String get chatBlockRecommendedNextSteps => 'Próximos pasos recomendados'; + @override String get couldNotLoadMemories => 'No se pudieron cargar los recuerdos'; diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index d87e087d0b7..4a669205ed4 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -9913,6 +9913,36 @@ class AppLocalizationsEt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Ülesanne'; + + @override + String get chatBlockGoal => 'Eesmärk'; + + @override + String get chatBlockConversation => 'Vestlus'; + + @override + String get chatBlockMemory => 'Mälestus'; + + @override + String get chatBlockQuestion => 'Küsimus'; + + @override + String get chatBlockOpenInGoals => 'Ava eesmärkides'; + + @override + String get chatBlockOpenConversation => 'Ava vestlus'; + + @override + String get chatBlockOpenInMemories => 'Ava mälestustes'; + + @override + String get chatBlockUnavailable => 'Pole enam saadaval'; + + @override + String get chatBlockRecommendedNextSteps => 'Soovitatud järgmised sammud'; + @override String get couldNotLoadMemories => 'Mälestusi ei õnnestunud laadida'; diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index 6e30c1eb370..1e8d76c349f 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -9919,6 +9919,36 @@ class AppLocalizationsFa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'وظیفه'; + + @override + String get chatBlockGoal => 'هدف'; + + @override + String get chatBlockConversation => 'گفتگو'; + + @override + String get chatBlockMemory => 'خاطره'; + + @override + String get chatBlockQuestion => 'پرسش'; + + @override + String get chatBlockOpenInGoals => 'باز کردن در اهداف'; + + @override + String get chatBlockOpenConversation => 'باز کردن گفتگو'; + + @override + String get chatBlockOpenInMemories => 'باز کردن در خاطرات'; + + @override + String get chatBlockUnavailable => 'دیگر در دسترس نیست'; + + @override + String get chatBlockRecommendedNextSteps => 'گام‌های بعدی پیشنهادی'; + @override String get couldNotLoadMemories => 'بارگذاری خاطرات ممکن نشد'; diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 0ae9e36a5d1..2563433fcd4 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -9920,6 +9920,36 @@ class AppLocalizationsFi extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tehtävä'; + + @override + String get chatBlockGoal => 'Tavoite'; + + @override + String get chatBlockConversation => 'Keskustelu'; + + @override + String get chatBlockMemory => 'Muisto'; + + @override + String get chatBlockQuestion => 'Kysymys'; + + @override + String get chatBlockOpenInGoals => 'Avaa Tavoitteissa'; + + @override + String get chatBlockOpenConversation => 'Avaa keskustelu'; + + @override + String get chatBlockOpenInMemories => 'Avaa Muistoissa'; + + @override + String get chatBlockUnavailable => 'Ei ole enää saatavilla'; + + @override + String get chatBlockRecommendedNextSteps => 'Suositellut seuraavat vaiheet'; + @override String get couldNotLoadMemories => 'Muistoja ei voitu ladata'; diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 012c6a5d124..824f9d9a6f6 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -10006,6 +10006,36 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tâche'; + + @override + String get chatBlockGoal => 'Objectif'; + + @override + String get chatBlockConversation => 'Conversation'; + + @override + String get chatBlockMemory => 'Souvenir'; + + @override + String get chatBlockQuestion => 'Question'; + + @override + String get chatBlockOpenInGoals => 'Ouvrir dans Objectifs'; + + @override + String get chatBlockOpenConversation => 'Ouvrir la conversation'; + + @override + String get chatBlockOpenInMemories => 'Ouvrir dans Souvenirs'; + + @override + String get chatBlockUnavailable => 'N’est plus disponible'; + + @override + String get chatBlockRecommendedNextSteps => 'Prochaines étapes recommandées'; + @override String get couldNotLoadMemories => 'Impossible de charger les souvenirs'; diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index d8d6e237d75..a7d64cd8146 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -9840,6 +9840,36 @@ class AppLocalizationsHe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'משימה'; + + @override + String get chatBlockGoal => 'יעד'; + + @override + String get chatBlockConversation => 'שיחה'; + + @override + String get chatBlockMemory => 'זיכרון'; + + @override + String get chatBlockQuestion => 'שאלה'; + + @override + String get chatBlockOpenInGoals => 'פתיחה ביעדים'; + + @override + String get chatBlockOpenConversation => 'פתיחת השיחה'; + + @override + String get chatBlockOpenInMemories => 'פתיחה בזיכרונות'; + + @override + String get chatBlockUnavailable => 'אינו זמין עוד'; + + @override + String get chatBlockRecommendedNextSteps => 'השלבים הבאים המומלצים'; + @override String get couldNotLoadMemories => 'לא ניתן לטעון את הזיכרונות'; diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index ec0aba1086e..560164af819 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -9898,6 +9898,36 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'कार्य'; + + @override + String get chatBlockGoal => 'लक्ष्य'; + + @override + String get chatBlockConversation => 'बातचीत'; + + @override + String get chatBlockMemory => 'स्मृति'; + + @override + String get chatBlockQuestion => 'प्रश्न'; + + @override + String get chatBlockOpenInGoals => 'लक्ष्यों में खोलें'; + + @override + String get chatBlockOpenConversation => 'बातचीत खोलें'; + + @override + String get chatBlockOpenInMemories => 'स्मृतियों में खोलें'; + + @override + String get chatBlockUnavailable => 'अब उपलब्ध नहीं है'; + + @override + String get chatBlockRecommendedNextSteps => 'अनुशंसित अगले कदम'; + @override String get couldNotLoadMemories => 'यादें लोड नहीं हो सकीं'; diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index 0ba8d1c76ad..6aff072e050 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -9955,6 +9955,36 @@ class AppLocalizationsHr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Zadatak'; + + @override + String get chatBlockGoal => 'Cilj'; + + @override + String get chatBlockConversation => 'Razgovor'; + + @override + String get chatBlockMemory => 'Sjećanje'; + + @override + String get chatBlockQuestion => 'Pitanje'; + + @override + String get chatBlockOpenInGoals => 'Otvori u Ciljevima'; + + @override + String get chatBlockOpenConversation => 'Otvori razgovor'; + + @override + String get chatBlockOpenInMemories => 'Otvori u Sjećanjima'; + + @override + String get chatBlockUnavailable => 'Više nije dostupno'; + + @override + String get chatBlockRecommendedNextSteps => 'Preporučeni sljedeći koraci'; + @override String get couldNotLoadMemories => 'Nije moguće učitati uspomene'; diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index eef752d12b4..7b2b2b79fba 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -9960,6 +9960,36 @@ class AppLocalizationsHu extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Feladat'; + + @override + String get chatBlockGoal => 'Cél'; + + @override + String get chatBlockConversation => 'Beszélgetés'; + + @override + String get chatBlockMemory => 'Emlék'; + + @override + String get chatBlockQuestion => 'Kérdés'; + + @override + String get chatBlockOpenInGoals => 'Megnyitás a Célokban'; + + @override + String get chatBlockOpenConversation => 'Beszélgetés megnyitása'; + + @override + String get chatBlockOpenInMemories => 'Megnyitás az Emlékekben'; + + @override + String get chatBlockUnavailable => 'Már nem érhető el'; + + @override + String get chatBlockRecommendedNextSteps => 'Javasolt következő lépések'; + @override String get couldNotLoadMemories => 'Nem sikerült betölteni az emlékeket'; diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 9960ae741a0..d2b71900bbb 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -9930,6 +9930,36 @@ class AppLocalizationsId extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tugas'; + + @override + String get chatBlockGoal => 'Tujuan'; + + @override + String get chatBlockConversation => 'Percakapan'; + + @override + String get chatBlockMemory => 'Memori'; + + @override + String get chatBlockQuestion => 'Pertanyaan'; + + @override + String get chatBlockOpenInGoals => 'Buka di Tujuan'; + + @override + String get chatBlockOpenConversation => 'Buka percakapan'; + + @override + String get chatBlockOpenInMemories => 'Buka di Memori'; + + @override + String get chatBlockUnavailable => 'Tidak lagi tersedia'; + + @override + String get chatBlockRecommendedNextSteps => 'Langkah berikutnya yang disarankan'; + @override String get couldNotLoadMemories => 'Tidak dapat memuat kenangan'; diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index 605ebb8243f..a3a42e844cf 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -9976,6 +9976,36 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Attività'; + + @override + String get chatBlockGoal => 'Obiettivo'; + + @override + String get chatBlockConversation => 'Conversazione'; + + @override + String get chatBlockMemory => 'Ricordo'; + + @override + String get chatBlockQuestion => 'Domanda'; + + @override + String get chatBlockOpenInGoals => 'Apri in Obiettivi'; + + @override + String get chatBlockOpenConversation => 'Apri conversazione'; + + @override + String get chatBlockOpenInMemories => 'Apri in Ricordi'; + + @override + String get chatBlockUnavailable => 'Non è più disponibile'; + + @override + String get chatBlockRecommendedNextSteps => 'Prossimi passi consigliati'; + @override String get couldNotLoadMemories => 'Impossibile caricare i ricordi'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index e51e60010c0..ad93d8df321 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -9750,6 +9750,36 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'タスク'; + + @override + String get chatBlockGoal => '目標'; + + @override + String get chatBlockConversation => '会話'; + + @override + String get chatBlockMemory => 'メモリー'; + + @override + String get chatBlockQuestion => '質問'; + + @override + String get chatBlockOpenInGoals => '目標で開く'; + + @override + String get chatBlockOpenConversation => '会話を開く'; + + @override + String get chatBlockOpenInMemories => 'メモリーで開く'; + + @override + String get chatBlockUnavailable => '現在は利用できません'; + + @override + String get chatBlockRecommendedNextSteps => 'おすすめの次のステップ'; + @override String get couldNotLoadMemories => '記憶を読み込めませんでした'; diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index ff8d4f8ac79..8e86d1b0e82 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -9951,6 +9951,36 @@ class AppLocalizationsKn extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'ಕಾರ್ಯ'; + + @override + String get chatBlockGoal => 'ಗುರಿ'; + + @override + String get chatBlockConversation => 'ಸಂಭಾಷಣೆ'; + + @override + String get chatBlockMemory => 'ನೆನಪು'; + + @override + String get chatBlockQuestion => 'ಪ್ರಶ್ನೆ'; + + @override + String get chatBlockOpenInGoals => 'ಗುರಿಗಳಲ್ಲಿ ತೆರೆಯಿರಿ'; + + @override + String get chatBlockOpenConversation => 'ಸಂಭಾಷಣೆ ತೆರೆಯಿರಿ'; + + @override + String get chatBlockOpenInMemories => 'ನೆನಪುಗಳಲ್ಲಿ ತೆರೆಯಿರಿ'; + + @override + String get chatBlockUnavailable => 'ಇನ್ನು ಲಭ್ಯವಿಲ್ಲ'; + + @override + String get chatBlockRecommendedNextSteps => 'ಶಿಫಾರಸು ಮಾಡಿದ ಮುಂದಿನ ಹಂತಗಳು'; + @override String get couldNotLoadMemories => 'ನೆನಪುಗಳನ್ನು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index ffe0144518e..664024bb895 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -9753,6 +9753,36 @@ class AppLocalizationsKo extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => '작업'; + + @override + String get chatBlockGoal => '목표'; + + @override + String get chatBlockConversation => '대화'; + + @override + String get chatBlockMemory => '메모리'; + + @override + String get chatBlockQuestion => '질문'; + + @override + String get chatBlockOpenInGoals => '목표에서 열기'; + + @override + String get chatBlockOpenConversation => '대화 열기'; + + @override + String get chatBlockOpenInMemories => '메모리에서 열기'; + + @override + String get chatBlockUnavailable => '더 이상 사용할 수 없음'; + + @override + String get chatBlockRecommendedNextSteps => '권장 다음 단계'; + @override String get couldNotLoadMemories => '추억을 불러올 수 없습니다'; diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index 289ba311d50..30221ed615c 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -9939,6 +9939,36 @@ class AppLocalizationsLt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Užduotis'; + + @override + String get chatBlockGoal => 'Tikslas'; + + @override + String get chatBlockConversation => 'Pokalbis'; + + @override + String get chatBlockMemory => 'Prisiminimas'; + + @override + String get chatBlockQuestion => 'Klausimas'; + + @override + String get chatBlockOpenInGoals => 'Atidaryti skiltyje „Tikslai“'; + + @override + String get chatBlockOpenConversation => 'Atidaryti pokalbį'; + + @override + String get chatBlockOpenInMemories => 'Atidaryti skiltyje „Prisiminimai“'; + + @override + String get chatBlockUnavailable => 'Nebepasiekiama'; + + @override + String get chatBlockRecommendedNextSteps => 'Rekomenduojami tolesni veiksmai'; + @override String get couldNotLoadMemories => 'Nepavyko įkelti prisiminimų'; diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index 929396c1b4a..cac7dd2ee1f 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -9943,6 +9943,36 @@ class AppLocalizationsLv extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Uzdevums'; + + @override + String get chatBlockGoal => 'Mērķis'; + + @override + String get chatBlockConversation => 'Saruna'; + + @override + String get chatBlockMemory => 'Atmiņa'; + + @override + String get chatBlockQuestion => 'Jautājums'; + + @override + String get chatBlockOpenInGoals => 'Atvērt sadaļā “Mērķi”'; + + @override + String get chatBlockOpenConversation => 'Atvērt sarunu'; + + @override + String get chatBlockOpenInMemories => 'Atvērt sadaļā “Atmiņas”'; + + @override + String get chatBlockUnavailable => 'Vairs nav pieejams'; + + @override + String get chatBlockRecommendedNextSteps => 'Ieteicamie nākamie soļi'; + @override String get couldNotLoadMemories => 'Neizdevās ielādēt atmiņas'; diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index 48013295207..430070020fd 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -9972,6 +9972,36 @@ class AppLocalizationsMk extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Цел'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Спомен'; + + @override + String get chatBlockQuestion => 'Прашање'; + + @override + String get chatBlockOpenInGoals => 'Отвори во Цели'; + + @override + String get chatBlockOpenConversation => 'Отвори разговор'; + + @override + String get chatBlockOpenInMemories => 'Отвори во Спомени'; + + @override + String get chatBlockUnavailable => 'Веќе не е достапно'; + + @override + String get chatBlockRecommendedNextSteps => 'Препорачани следни чекори'; + @override String get couldNotLoadMemories => 'Не можеа да се вчитаат спомените'; diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index a2dbba1bf05..3352f357084 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -9928,6 +9928,36 @@ class AppLocalizationsMr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'कार्य'; + + @override + String get chatBlockGoal => 'ध्येय'; + + @override + String get chatBlockConversation => 'संभाषण'; + + @override + String get chatBlockMemory => 'स्मृती'; + + @override + String get chatBlockQuestion => 'प्रश्न'; + + @override + String get chatBlockOpenInGoals => 'ध्येयांमध्ये उघडा'; + + @override + String get chatBlockOpenConversation => 'संभाषण उघडा'; + + @override + String get chatBlockOpenInMemories => 'स्मृतींमध्ये उघडा'; + + @override + String get chatBlockUnavailable => 'आता उपलब्ध नाही'; + + @override + String get chatBlockRecommendedNextSteps => 'शिफारस केलेली पुढील पावले'; + @override String get couldNotLoadMemories => 'आठवणी लोड करता आल्या नाहीत'; diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index c3fc5c71875..bc3dbbde096 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -9945,6 +9945,36 @@ class AppLocalizationsMs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tugas'; + + @override + String get chatBlockGoal => 'Matlamat'; + + @override + String get chatBlockConversation => 'Perbualan'; + + @override + String get chatBlockMemory => 'Memori'; + + @override + String get chatBlockQuestion => 'Soalan'; + + @override + String get chatBlockOpenInGoals => 'Buka dalam Matlamat'; + + @override + String get chatBlockOpenConversation => 'Buka perbualan'; + + @override + String get chatBlockOpenInMemories => 'Buka dalam Memori'; + + @override + String get chatBlockUnavailable => 'Tidak lagi tersedia'; + + @override + String get chatBlockRecommendedNextSteps => 'Langkah seterusnya yang disyorkan'; + @override String get couldNotLoadMemories => 'Tidak dapat memuatkan kenangan'; diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index 78f9de88dcc..2fe8ad57776 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -9946,6 +9946,36 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Taak'; + + @override + String get chatBlockGoal => 'Doel'; + + @override + String get chatBlockConversation => 'Gesprek'; + + @override + String get chatBlockMemory => 'Herinnering'; + + @override + String get chatBlockQuestion => 'Vraag'; + + @override + String get chatBlockOpenInGoals => 'Openen in Doelen'; + + @override + String get chatBlockOpenConversation => 'Gesprek openen'; + + @override + String get chatBlockOpenInMemories => 'Openen in Herinneringen'; + + @override + String get chatBlockUnavailable => 'Niet langer beschikbaar'; + + @override + String get chatBlockRecommendedNextSteps => 'Aanbevolen volgende stappen'; + @override String get couldNotLoadMemories => 'Herinneringen konden niet worden geladen'; diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index cb4ba790818..e7c1c78c828 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -9917,6 +9917,36 @@ class AppLocalizationsNo extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Oppgave'; + + @override + String get chatBlockGoal => 'Mål'; + + @override + String get chatBlockConversation => 'Samtale'; + + @override + String get chatBlockMemory => 'Minne'; + + @override + String get chatBlockQuestion => 'Spørsmål'; + + @override + String get chatBlockOpenInGoals => 'Åpne i Mål'; + + @override + String get chatBlockOpenConversation => 'Åpne samtale'; + + @override + String get chatBlockOpenInMemories => 'Åpne i Minner'; + + @override + String get chatBlockUnavailable => 'Ikke lenger tilgjengelig'; + + @override + String get chatBlockRecommendedNextSteps => 'Anbefalte neste trinn'; + @override String get couldNotLoadMemories => 'Kunne ikke laste minner'; diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index 7488119e7ef..1482f11889d 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -9949,6 +9949,36 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Zadanie'; + + @override + String get chatBlockGoal => 'Cel'; + + @override + String get chatBlockConversation => 'Rozmowa'; + + @override + String get chatBlockMemory => 'Wspomnienie'; + + @override + String get chatBlockQuestion => 'Pytanie'; + + @override + String get chatBlockOpenInGoals => 'Otwórz w Celach'; + + @override + String get chatBlockOpenConversation => 'Otwórz rozmowę'; + + @override + String get chatBlockOpenInMemories => 'Otwórz we Wspomnieniach'; + + @override + String get chatBlockUnavailable => 'Już niedostępne'; + + @override + String get chatBlockRecommendedNextSteps => 'Zalecane kolejne kroki'; + @override String get couldNotLoadMemories => 'Nie udało się wczytać wspomnień'; diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index 8214c4ef4af..239d9d66237 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -9928,6 +9928,36 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Tarefa'; + + @override + String get chatBlockGoal => 'Meta'; + + @override + String get chatBlockConversation => 'Conversa'; + + @override + String get chatBlockMemory => 'Memória'; + + @override + String get chatBlockQuestion => 'Pergunta'; + + @override + String get chatBlockOpenInGoals => 'Abrir em Metas'; + + @override + String get chatBlockOpenConversation => 'Abrir conversa'; + + @override + String get chatBlockOpenInMemories => 'Abrir em Memórias'; + + @override + String get chatBlockUnavailable => 'Já não está disponível'; + + @override + String get chatBlockRecommendedNextSteps => 'Próximos passos recomendados'; + @override String get couldNotLoadMemories => 'Não foi possível carregar as memórias'; diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index aa83e565d40..a89944ab9e2 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -9966,6 +9966,36 @@ class AppLocalizationsRo extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Sarcină'; + + @override + String get chatBlockGoal => 'Obiectiv'; + + @override + String get chatBlockConversation => 'Conversație'; + + @override + String get chatBlockMemory => 'Amintire'; + + @override + String get chatBlockQuestion => 'Întrebare'; + + @override + String get chatBlockOpenInGoals => 'Deschide în Obiective'; + + @override + String get chatBlockOpenConversation => 'Deschide conversația'; + + @override + String get chatBlockOpenInMemories => 'Deschide în Amintiri'; + + @override + String get chatBlockUnavailable => 'Nu mai este disponibil'; + + @override + String get chatBlockRecommendedNextSteps => 'Pașii următori recomandați'; + @override String get couldNotLoadMemories => 'Nu s-au putut încărca amintirile'; diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index aafcbf383de..de5074af511 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -9956,6 +9956,36 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Цель'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Воспоминание'; + + @override + String get chatBlockQuestion => 'Вопрос'; + + @override + String get chatBlockOpenInGoals => 'Открыть в «Целях»'; + + @override + String get chatBlockOpenConversation => 'Открыть разговор'; + + @override + String get chatBlockOpenInMemories => 'Открыть в «Воспоминаниях»'; + + @override + String get chatBlockUnavailable => 'Больше недоступно'; + + @override + String get chatBlockRecommendedNextSteps => 'Рекомендуемые следующие шаги'; + @override String get couldNotLoadMemories => 'Не удалось загрузить воспоминания'; diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index 02a43e1f3af..38e2b854a4c 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -9912,6 +9912,36 @@ class AppLocalizationsSk extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Úloha'; + + @override + String get chatBlockGoal => 'Cieľ'; + + @override + String get chatBlockConversation => 'Konverzácia'; + + @override + String get chatBlockMemory => 'Spomienka'; + + @override + String get chatBlockQuestion => 'Otázka'; + + @override + String get chatBlockOpenInGoals => 'Otvoriť v Cieľoch'; + + @override + String get chatBlockOpenConversation => 'Otvoriť konverzáciu'; + + @override + String get chatBlockOpenInMemories => 'Otvoriť v Spomienkach'; + + @override + String get chatBlockUnavailable => 'Už nie je k dispozícii'; + + @override + String get chatBlockRecommendedNextSteps => 'Odporúčané ďalšie kroky'; + @override String get couldNotLoadMemories => 'Nepodarilo sa načítať spomienky'; diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index 2992e600c4c..e3ba1a2092e 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -9950,6 +9950,36 @@ class AppLocalizationsSl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Opravilo'; + + @override + String get chatBlockGoal => 'Cilj'; + + @override + String get chatBlockConversation => 'Pogovor'; + + @override + String get chatBlockMemory => 'Spomin'; + + @override + String get chatBlockQuestion => 'Vprašanje'; + + @override + String get chatBlockOpenInGoals => 'Odpri v Ciljih'; + + @override + String get chatBlockOpenConversation => 'Odpri pogovor'; + + @override + String get chatBlockOpenInMemories => 'Odpri v Spominih'; + + @override + String get chatBlockUnavailable => 'Ni več na voljo'; + + @override + String get chatBlockRecommendedNextSteps => 'Priporočeni naslednji koraki'; + @override String get couldNotLoadMemories => 'Spominov ni bilo mogoče naložiti'; diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index 7f9ed71c76a..f471b31a438 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -9935,6 +9935,36 @@ class AppLocalizationsSr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Задатак'; + + @override + String get chatBlockGoal => 'Циљ'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Сећање'; + + @override + String get chatBlockQuestion => 'Питање'; + + @override + String get chatBlockOpenInGoals => 'Отвори у Циљевима'; + + @override + String get chatBlockOpenConversation => 'Отвори разговор'; + + @override + String get chatBlockOpenInMemories => 'Отвори у Сећањима'; + + @override + String get chatBlockUnavailable => 'Више није доступно'; + + @override + String get chatBlockRecommendedNextSteps => 'Препоручени следећи кораци'; + @override String get couldNotLoadMemories => 'Није могуће учитати успомене'; diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 9b57e68255f..a4a0afe7d24 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -9923,6 +9923,36 @@ class AppLocalizationsSv extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Uppgift'; + + @override + String get chatBlockGoal => 'Mål'; + + @override + String get chatBlockConversation => 'Konversation'; + + @override + String get chatBlockMemory => 'Minne'; + + @override + String get chatBlockQuestion => 'Fråga'; + + @override + String get chatBlockOpenInGoals => 'Öppna i Mål'; + + @override + String get chatBlockOpenConversation => 'Öppna konversation'; + + @override + String get chatBlockOpenInMemories => 'Öppna i Minnen'; + + @override + String get chatBlockUnavailable => 'Inte längre tillgänglig'; + + @override + String get chatBlockRecommendedNextSteps => 'Rekommenderade nästa steg'; + @override String get couldNotLoadMemories => 'Kunde inte läsa in minnen'; diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index 9ba6e689559..9dc1a300923 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -9989,6 +9989,36 @@ class AppLocalizationsTa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'பணி'; + + @override + String get chatBlockGoal => 'இலக்கு'; + + @override + String get chatBlockConversation => 'உரையாடல்'; + + @override + String get chatBlockMemory => 'நினைவு'; + + @override + String get chatBlockQuestion => 'கேள்வி'; + + @override + String get chatBlockOpenInGoals => 'இலக்குகளில் திறக்க'; + + @override + String get chatBlockOpenConversation => 'உரையாடலைத் திறக்க'; + + @override + String get chatBlockOpenInMemories => 'நினைவுகளில் திறக்க'; + + @override + String get chatBlockUnavailable => 'இனி கிடைக்கவில்லை'; + + @override + String get chatBlockRecommendedNextSteps => 'பரிந்துரைக்கப்பட்ட அடுத்த படிகள்'; + @override String get couldNotLoadMemories => 'நினைவுகளை ஏற்ற முடியவில்லை'; diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index 31a4fcbec1e..a3f84b3a770 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -9968,6 +9968,36 @@ class AppLocalizationsTe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'పని'; + + @override + String get chatBlockGoal => 'లక్ష్యం'; + + @override + String get chatBlockConversation => 'సంభాషణ'; + + @override + String get chatBlockMemory => 'జ్ఞాపకం'; + + @override + String get chatBlockQuestion => 'ప్రశ్న'; + + @override + String get chatBlockOpenInGoals => 'లక్ష్యాలలో తెరవండి'; + + @override + String get chatBlockOpenConversation => 'సంభాషణను తెరవండి'; + + @override + String get chatBlockOpenInMemories => 'జ్ఞాపకాలలో తెరవండి'; + + @override + String get chatBlockUnavailable => 'ఇకపై అందుబాటులో లేదు'; + + @override + String get chatBlockRecommendedNextSteps => 'సిఫార్సు చేసిన తదుపరి దశలు'; + @override String get couldNotLoadMemories => 'జ్ఞాపకాలను లోడ్ చేయలేకపోయాం'; diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index 2fad686aefc..91294739737 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -9862,6 +9862,36 @@ class AppLocalizationsTh extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'งาน'; + + @override + String get chatBlockGoal => 'เป้าหมาย'; + + @override + String get chatBlockConversation => 'บทสนทนา'; + + @override + String get chatBlockMemory => 'ความทรงจำ'; + + @override + String get chatBlockQuestion => 'คำถาม'; + + @override + String get chatBlockOpenInGoals => 'เปิดในเป้าหมาย'; + + @override + String get chatBlockOpenConversation => 'เปิดบทสนทนา'; + + @override + String get chatBlockOpenInMemories => 'เปิดในความทรงจำ'; + + @override + String get chatBlockUnavailable => 'ไม่พร้อมใช้งานอีกต่อไป'; + + @override + String get chatBlockRecommendedNextSteps => 'ขั้นตอนถัดไปที่แนะนำ'; + @override String get couldNotLoadMemories => 'ไม่สามารถโหลดความทรงจำได้'; diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index 678d2229206..e0fdd7b2aa3 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -10010,6 +10010,36 @@ class AppLocalizationsTl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Gawain'; + + @override + String get chatBlockGoal => 'Layunin'; + + @override + String get chatBlockConversation => 'Pag-uusap'; + + @override + String get chatBlockMemory => 'Alaala'; + + @override + String get chatBlockQuestion => 'Tanong'; + + @override + String get chatBlockOpenInGoals => 'Buksan sa Mga Layunin'; + + @override + String get chatBlockOpenConversation => 'Buksan ang pag-uusap'; + + @override + String get chatBlockOpenInMemories => 'Buksan sa Mga Alaala'; + + @override + String get chatBlockUnavailable => 'Hindi na available'; + + @override + String get chatBlockRecommendedNextSteps => 'Mga inirerekomendang susunod na hakbang'; + @override String get couldNotLoadMemories => 'Hindi ma-load ang mga alaala'; diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index c0583a7efb2..4b1dab2163e 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -9931,6 +9931,36 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Görev'; + + @override + String get chatBlockGoal => 'Hedef'; + + @override + String get chatBlockConversation => 'Konuşma'; + + @override + String get chatBlockMemory => 'Anı'; + + @override + String get chatBlockQuestion => 'Soru'; + + @override + String get chatBlockOpenInGoals => 'Hedefler’de aç'; + + @override + String get chatBlockOpenConversation => 'Konuşmayı aç'; + + @override + String get chatBlockOpenInMemories => 'Anılar’da aç'; + + @override + String get chatBlockUnavailable => 'Artık kullanılamıyor'; + + @override + String get chatBlockRecommendedNextSteps => 'Önerilen sonraki adımlar'; + @override String get couldNotLoadMemories => 'Anılar yüklenemedi'; diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index e19a4caf137..6cada0943f8 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -9941,6 +9941,36 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Завдання'; + + @override + String get chatBlockGoal => 'Ціль'; + + @override + String get chatBlockConversation => 'Розмова'; + + @override + String get chatBlockMemory => 'Спогад'; + + @override + String get chatBlockQuestion => 'Питання'; + + @override + String get chatBlockOpenInGoals => 'Відкрити в «Цілях»'; + + @override + String get chatBlockOpenConversation => 'Відкрити розмову'; + + @override + String get chatBlockOpenInMemories => 'Відкрити у «Спогадах»'; + + @override + String get chatBlockUnavailable => 'Більше недоступно'; + + @override + String get chatBlockRecommendedNextSteps => 'Рекомендовані наступні кроки'; + @override String get couldNotLoadMemories => 'Не вдалося завантажити спогади'; diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index f9488497d4a..d3e774f267d 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -9931,6 +9931,36 @@ class AppLocalizationsUr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'کام'; + + @override + String get chatBlockGoal => 'ہدف'; + + @override + String get chatBlockConversation => 'گفتگو'; + + @override + String get chatBlockMemory => 'یاد'; + + @override + String get chatBlockQuestion => 'سوال'; + + @override + String get chatBlockOpenInGoals => 'اہداف میں کھولیں'; + + @override + String get chatBlockOpenConversation => 'گفتگو کھولیں'; + + @override + String get chatBlockOpenInMemories => 'یادوں میں کھولیں'; + + @override + String get chatBlockUnavailable => 'اب دستیاب نہیں'; + + @override + String get chatBlockRecommendedNextSteps => 'تجویز کردہ اگلے اقدامات'; + @override String get couldNotLoadMemories => 'یادیں لوڈ نہیں ہو سکیں'; diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index 322c2cf7be5..dfdd64641c6 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -9914,6 +9914,36 @@ class AppLocalizationsVi extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => 'Nhiệm vụ'; + + @override + String get chatBlockGoal => 'Mục tiêu'; + + @override + String get chatBlockConversation => 'Cuộc trò chuyện'; + + @override + String get chatBlockMemory => 'Ký ức'; + + @override + String get chatBlockQuestion => 'Câu hỏi'; + + @override + String get chatBlockOpenInGoals => 'Mở trong Mục tiêu'; + + @override + String get chatBlockOpenConversation => 'Mở cuộc trò chuyện'; + + @override + String get chatBlockOpenInMemories => 'Mở trong Ký ức'; + + @override + String get chatBlockUnavailable => 'Không còn khả dụng'; + + @override + String get chatBlockRecommendedNextSteps => 'Các bước tiếp theo được đề xuất'; + @override String get couldNotLoadMemories => 'Không thể tải ký ức'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index efd244e0e48..9fbf1c44a71 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -9731,6 +9731,36 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + @override + String get chatBlockTask => '任务'; + + @override + String get chatBlockGoal => '目标'; + + @override + String get chatBlockConversation => '对话'; + + @override + String get chatBlockMemory => '记忆'; + + @override + String get chatBlockQuestion => '问题'; + + @override + String get chatBlockOpenInGoals => '在目标中打开'; + + @override + String get chatBlockOpenConversation => '打开对话'; + + @override + String get chatBlockOpenInMemories => '在记忆中打开'; + + @override + String get chatBlockUnavailable => '已不再可用'; + + @override + String get chatBlockRecommendedNextSteps => '建议的后续步骤'; + @override String get couldNotLoadMemories => '无法加载回忆'; diff --git a/app/lib/l10n/app_lt.arb b/app/lib/l10n/app_lt.arb index e7582ce1bd1..f48aa95c3eb 100644 --- a/app/lib/l10n/app_lt.arb +++ b/app/lib/l10n/app_lt.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Pendant atmintis pilna ir jis vis dar įrašymo režime, todėl išsaugoto garso perkelti negalima. Paspauskite Pendant mygtuką, kad sustabdytumėte įrašymą, tada sinchronizuokite iš naujo.", "conversationsNotCapturedCount": "Neįrašyta ({count})", "transcriptionNoAudio": "Transkripcija negauna garso", + "chatBlockTask": "Užduotis", + "chatBlockGoal": "Tikslas", + "chatBlockConversation": "Pokalbis", + "chatBlockMemory": "Prisiminimas", + "chatBlockQuestion": "Klausimas", + "chatBlockOpenInGoals": "Atidaryti skiltyje „Tikslai“", + "chatBlockOpenConversation": "Atidaryti pokalbį", + "chatBlockOpenInMemories": "Atidaryti skiltyje „Prisiminimai“", + "chatBlockUnavailable": "Nebepasiekiama", + "chatBlockRecommendedNextSteps": "Rekomenduojami tolesni veiksmai", "couldNotLoadMemories": "Nepavyko įkelti prisiminimų", "couldNotLoadKnowledgeGraph": "Nepavyko įkelti žinių grafo" } diff --git a/app/lib/l10n/app_lv.arb b/app/lib/l10n/app_lv.arb index 6b00e7f8bc1..cdbbb853cfd 100644 --- a/app/lib/l10n/app_lv.arb +++ b/app/lib/l10n/app_lv.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Pendant atmiņa ir pilna, un tas joprojām ir ierakstīšanas režīmā, tāpēc saglabāto audio nevar pārsūtīt. Nospiediet Pendant pogu, lai apturētu ierakstīšanu, un pēc tam sinhronizējiet vēlreiz.", "conversationsNotCapturedCount": "Nav ierakstīts ({count})", "transcriptionNoAudio": "Transkripcija nesaņem audio", + "chatBlockTask": "Uzdevums", + "chatBlockGoal": "Mērķis", + "chatBlockConversation": "Saruna", + "chatBlockMemory": "Atmiņa", + "chatBlockQuestion": "Jautājums", + "chatBlockOpenInGoals": "Atvērt sadaļā “Mērķi”", + "chatBlockOpenConversation": "Atvērt sarunu", + "chatBlockOpenInMemories": "Atvērt sadaļā “Atmiņas”", + "chatBlockUnavailable": "Vairs nav pieejams", + "chatBlockRecommendedNextSteps": "Ieteicamie nākamie soļi", "couldNotLoadMemories": "Neizdevās ielādēt atmiņas", "couldNotLoadKnowledgeGraph": "Neizdevās ielādēt zināšanu grafu" } diff --git a/app/lib/l10n/app_mk.arb b/app/lib/l10n/app_mk.arb index 338f2973447..53c50a66c36 100644 --- a/app/lib/l10n/app_mk.arb +++ b/app/lib/l10n/app_mk.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Меморијата на Pendant е полна и тој сè уште е во режим на снимање, па зачуваното аудио не може да се пренесе. Притиснете го копчето на Pendant за да го запрете снимањето, а потоа синхронизирајте повторно.", "conversationsNotCapturedCount": "Не е снимено ({count})", "transcriptionNoAudio": "Транскрипцијата не прима аудио", + "chatBlockTask": "Задача", + "chatBlockGoal": "Цел", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Спомен", + "chatBlockQuestion": "Прашање", + "chatBlockOpenInGoals": "Отвори во Цели", + "chatBlockOpenConversation": "Отвори разговор", + "chatBlockOpenInMemories": "Отвори во Спомени", + "chatBlockUnavailable": "Веќе не е достапно", + "chatBlockRecommendedNextSteps": "Препорачани следни чекори", "couldNotLoadMemories": "Не можеа да се вчитаат спомените", "couldNotLoadKnowledgeGraph": "Не можеше да се вчита графот на знаење" } diff --git a/app/lib/l10n/app_mr.arb b/app/lib/l10n/app_mr.arb index 3a1e290d773..c970eaf44e9 100644 --- a/app/lib/l10n/app_mr.arb +++ b/app/lib/l10n/app_mr.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pendant चे स्टोरेज भरले आहे आणि ते अजूनही रेकॉर्डिंग मोडमध्ये आहे, त्यामुळे साठवलेला ऑडिओ हस्तांतरित करता येत नाही. रेकॉर्डिंग थांबवण्यासाठी Pendant चे बटण दाबा, नंतर पुन्हा सिंक करा.", "conversationsNotCapturedCount": "रेकॉर्ड झाले नाही ({count})", "transcriptionNoAudio": "ट्रान्सक्रिप्शन ऑडिओ घेत नाही", + "chatBlockTask": "कार्य", + "chatBlockGoal": "ध्येय", + "chatBlockConversation": "संभाषण", + "chatBlockMemory": "स्मृती", + "chatBlockQuestion": "प्रश्न", + "chatBlockOpenInGoals": "ध्येयांमध्ये उघडा", + "chatBlockOpenConversation": "संभाषण उघडा", + "chatBlockOpenInMemories": "स्मृतींमध्ये उघडा", + "chatBlockUnavailable": "आता उपलब्ध नाही", + "chatBlockRecommendedNextSteps": "शिफारस केलेली पुढील पावले", "couldNotLoadMemories": "आठवणी लोड करता आल्या नाहीत", "couldNotLoadKnowledgeGraph": "ज्ञान आलेख लोड करता आला नाही" } diff --git a/app/lib/l10n/app_ms.arb b/app/lib/l10n/app_ms.arb index d799938fd93..8bd20b78784 100644 --- a/app/lib/l10n/app_ms.arb +++ b/app/lib/l10n/app_ms.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Storan Pendant penuh dan ia masih dalam mod rakaman, jadi audio yang tersimpan tidak dapat dipindahkan. Tekan butang Pendant untuk menghentikan rakaman, kemudian segerakkan semula.", "conversationsNotCapturedCount": "Tidak dirakam ({count})", "transcriptionNoAudio": "Transkripsi tidak menerima audio", + "chatBlockTask": "Tugas", + "chatBlockGoal": "Matlamat", + "chatBlockConversation": "Perbualan", + "chatBlockMemory": "Memori", + "chatBlockQuestion": "Soalan", + "chatBlockOpenInGoals": "Buka dalam Matlamat", + "chatBlockOpenConversation": "Buka perbualan", + "chatBlockOpenInMemories": "Buka dalam Memori", + "chatBlockUnavailable": "Tidak lagi tersedia", + "chatBlockRecommendedNextSteps": "Langkah seterusnya yang disyorkan", "couldNotLoadMemories": "Tidak dapat memuatkan kenangan", "couldNotLoadKnowledgeGraph": "Tidak dapat memuatkan graf pengetahuan" } diff --git a/app/lib/l10n/app_nl.arb b/app/lib/l10n/app_nl.arb index 80e3ac8cb61..9e6b2383734 100644 --- a/app/lib/l10n/app_nl.arb +++ b/app/lib/l10n/app_nl.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "De opslag van je Pendant is vol en hij staat nog in de opnamemodus, dus de opgeslagen audio kan niet worden overgedragen. Druk op de knop van de Pendant om de opname te stoppen en synchroniseer daarna opnieuw.", "conversationsNotCapturedCount": "Niet opgenomen ({count})", "transcriptionNoAudio": "Transcriptie ontvangt geen audio", + "chatBlockTask": "Taak", + "chatBlockGoal": "Doel", + "chatBlockConversation": "Gesprek", + "chatBlockMemory": "Herinnering", + "chatBlockQuestion": "Vraag", + "chatBlockOpenInGoals": "Openen in Doelen", + "chatBlockOpenConversation": "Gesprek openen", + "chatBlockOpenInMemories": "Openen in Herinneringen", + "chatBlockUnavailable": "Niet langer beschikbaar", + "chatBlockRecommendedNextSteps": "Aanbevolen volgende stappen", "couldNotLoadMemories": "Herinneringen konden niet worden geladen", "couldNotLoadKnowledgeGraph": "Kennisgrafiek kon niet worden geladen" } diff --git a/app/lib/l10n/app_no.arb b/app/lib/l10n/app_no.arb index 8509b39cc01..11a06635cb3 100644 --- a/app/lib/l10n/app_no.arb +++ b/app/lib/l10n/app_no.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Lagringen på Pendant er full, og den er fortsatt i opptaksmodus, så den lagrede lyden kan ikke overføres. Trykk på knappen på Pendant for å stoppe opptaket, og synkroniser på nytt.", "conversationsNotCapturedCount": "Ikke fanget opp ({count})", "transcriptionNoAudio": "Transkripsjon mottar ikke lyd", + "chatBlockTask": "Oppgave", + "chatBlockGoal": "Mål", + "chatBlockConversation": "Samtale", + "chatBlockMemory": "Minne", + "chatBlockQuestion": "Spørsmål", + "chatBlockOpenInGoals": "Åpne i Mål", + "chatBlockOpenConversation": "Åpne samtale", + "chatBlockOpenInMemories": "Åpne i Minner", + "chatBlockUnavailable": "Ikke lenger tilgjengelig", + "chatBlockRecommendedNextSteps": "Anbefalte neste trinn", "couldNotLoadMemories": "Kunne ikke laste minner", "couldNotLoadKnowledgeGraph": "Kunne ikke laste kunnskapsgrafen" } diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index e8eb77b67f5..73e9e8dce4e 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -3270,6 +3270,16 @@ "pendantFullSyncBlocked": "Pamięć Pendanta jest pełna i wciąż jest on w trybie nagrywania, więc zapisanego dźwięku nie można przenieść. Naciśnij przycisk Pendanta, aby zatrzymać nagrywanie, a następnie zsynchronizuj ponownie.", "conversationsNotCapturedCount": "Nie nagrano ({count})", "transcriptionNoAudio": "Transkrypcja nie odbiera dźwięku", + "chatBlockTask": "Zadanie", + "chatBlockGoal": "Cel", + "chatBlockConversation": "Rozmowa", + "chatBlockMemory": "Wspomnienie", + "chatBlockQuestion": "Pytanie", + "chatBlockOpenInGoals": "Otwórz w Celach", + "chatBlockOpenConversation": "Otwórz rozmowę", + "chatBlockOpenInMemories": "Otwórz we Wspomnieniach", + "chatBlockUnavailable": "Już niedostępne", + "chatBlockRecommendedNextSteps": "Zalecane kolejne kroki", "couldNotLoadMemories": "Nie udało się wczytać wspomnień", "couldNotLoadKnowledgeGraph": "Nie udało się wczytać grafu wiedzy" } diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index 1f7af2ff50a..a858445e1e4 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -3271,6 +3271,16 @@ "pendantFullSyncBlocked": "O armazenamento do Pendant está cheio e ele ainda está no modo de gravação, então o áudio armazenado não pode ser transferido. Pressione o botão do Pendant para parar a gravação e sincronize novamente.", "conversationsNotCapturedCount": "Não capturado ({count})", "transcriptionNoAudio": "A transcrição não está recebendo áudio", + "chatBlockTask": "Tarefa", + "chatBlockGoal": "Meta", + "chatBlockConversation": "Conversa", + "chatBlockMemory": "Memória", + "chatBlockQuestion": "Pergunta", + "chatBlockOpenInGoals": "Abrir em Metas", + "chatBlockOpenConversation": "Abrir conversa", + "chatBlockOpenInMemories": "Abrir em Memórias", + "chatBlockUnavailable": "Já não está disponível", + "chatBlockRecommendedNextSteps": "Próximos passos recomendados", "couldNotLoadMemories": "Não foi possível carregar as memórias", "couldNotLoadKnowledgeGraph": "Não foi possível carregar o grafo de conhecimento" } diff --git a/app/lib/l10n/app_ro.arb b/app/lib/l10n/app_ro.arb index ebc56bc55df..db3f9f7bc9f 100644 --- a/app/lib/l10n/app_ro.arb +++ b/app/lib/l10n/app_ro.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Spațiul de stocare al Pendantului este plin și acesta este încă în modul de înregistrare, așa că audio-ul stocat nu poate fi transferat. Apăsați butonul Pendantului pentru a opri înregistrarea, apoi sincronizați din nou.", "conversationsNotCapturedCount": "Neînregistrat ({count})", "transcriptionNoAudio": "Transcrierea nu primește audio", + "chatBlockTask": "Sarcină", + "chatBlockGoal": "Obiectiv", + "chatBlockConversation": "Conversație", + "chatBlockMemory": "Amintire", + "chatBlockQuestion": "Întrebare", + "chatBlockOpenInGoals": "Deschide în Obiective", + "chatBlockOpenConversation": "Deschide conversația", + "chatBlockOpenInMemories": "Deschide în Amintiri", + "chatBlockUnavailable": "Nu mai este disponibil", + "chatBlockRecommendedNextSteps": "Pașii următori recomandați", "couldNotLoadMemories": "Nu s-au putut încărca amintirile", "couldNotLoadKnowledgeGraph": "Nu s-a putut încărca graful de cunoștințe" } diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index b35e2edc4de..0d279136eed 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -3270,6 +3270,16 @@ "pendantFullSyncBlocked": "Память Pendant заполнена, и он всё ещё в режиме записи, поэтому сохранённое аудио нельзя передать. Нажмите кнопку Pendant, чтобы остановить запись, затем синхронизируйте снова.", "conversationsNotCapturedCount": "Не записано ({count})", "transcriptionNoAudio": "Транскрипция не получает аудио", + "chatBlockTask": "Задача", + "chatBlockGoal": "Цель", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Воспоминание", + "chatBlockQuestion": "Вопрос", + "chatBlockOpenInGoals": "Открыть в «Целях»", + "chatBlockOpenConversation": "Открыть разговор", + "chatBlockOpenInMemories": "Открыть в «Воспоминаниях»", + "chatBlockUnavailable": "Больше недоступно", + "chatBlockRecommendedNextSteps": "Рекомендуемые следующие шаги", "couldNotLoadMemories": "Не удалось загрузить воспоминания", "couldNotLoadKnowledgeGraph": "Не удалось загрузить граф знаний" } diff --git a/app/lib/l10n/app_sk.arb b/app/lib/l10n/app_sk.arb index bf2d5e6e560..10f26a9b897 100644 --- a/app/lib/l10n/app_sk.arb +++ b/app/lib/l10n/app_sk.arb @@ -3240,6 +3240,16 @@ "pendantFullSyncBlocked": "Úložisko Pendantu je plné a stále je v režime nahrávania, takže uložený zvuk nemožno preniesť. Stlačením tlačidla na Pendante zastavte nahrávanie a potom znova synchronizujte.", "conversationsNotCapturedCount": "Nezaznamenané ({count})", "transcriptionNoAudio": "Transkripcia neprijíma zvuk", + "chatBlockTask": "Úloha", + "chatBlockGoal": "Cieľ", + "chatBlockConversation": "Konverzácia", + "chatBlockMemory": "Spomienka", + "chatBlockQuestion": "Otázka", + "chatBlockOpenInGoals": "Otvoriť v Cieľoch", + "chatBlockOpenConversation": "Otvoriť konverzáciu", + "chatBlockOpenInMemories": "Otvoriť v Spomienkach", + "chatBlockUnavailable": "Už nie je k dispozícii", + "chatBlockRecommendedNextSteps": "Odporúčané ďalšie kroky", "couldNotLoadMemories": "Nepodarilo sa načítať spomienky", "couldNotLoadKnowledgeGraph": "Nepodarilo sa načítať graf znalostí" } diff --git a/app/lib/l10n/app_sl.arb b/app/lib/l10n/app_sl.arb index 0546dc454d6..9ee46505d42 100644 --- a/app/lib/l10n/app_sl.arb +++ b/app/lib/l10n/app_sl.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pomnilnik Pendanta je poln in je še vedno v načinu snemanja, zato shranjenega zvoka ni mogoče prenesti. Pritisnite gumb na Pendantu, da ustavite snemanje, nato znova sinhronizirajte.", "conversationsNotCapturedCount": "Ni posneto ({count})", "transcriptionNoAudio": "Transkripcija ne prejema zvoka", + "chatBlockTask": "Opravilo", + "chatBlockGoal": "Cilj", + "chatBlockConversation": "Pogovor", + "chatBlockMemory": "Spomin", + "chatBlockQuestion": "Vprašanje", + "chatBlockOpenInGoals": "Odpri v Ciljih", + "chatBlockOpenConversation": "Odpri pogovor", + "chatBlockOpenInMemories": "Odpri v Spominih", + "chatBlockUnavailable": "Ni več na voljo", + "chatBlockRecommendedNextSteps": "Priporočeni naslednji koraki", "couldNotLoadMemories": "Spominov ni bilo mogoče naložiti", "couldNotLoadKnowledgeGraph": "Grafa znanja ni bilo mogoče naložiti" } diff --git a/app/lib/l10n/app_sr.arb b/app/lib/l10n/app_sr.arb index 281fe114480..0de9c34b0cf 100644 --- a/app/lib/l10n/app_sr.arb +++ b/app/lib/l10n/app_sr.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Меморија Pendant-а је пуна и он је и даље у режиму снимања, па сачувани звук не може да се пренесе. Притисните дугме на Pendant-у да зауставите снимање, а затим поново синхронизујте.", "conversationsNotCapturedCount": "Није снимљено ({count})", "transcriptionNoAudio": "Транскрипција не прима аудио", + "chatBlockTask": "Задатак", + "chatBlockGoal": "Циљ", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Сећање", + "chatBlockQuestion": "Питање", + "chatBlockOpenInGoals": "Отвори у Циљевима", + "chatBlockOpenConversation": "Отвори разговор", + "chatBlockOpenInMemories": "Отвори у Сећањима", + "chatBlockUnavailable": "Више није доступно", + "chatBlockRecommendedNextSteps": "Препоручени следећи кораци", "couldNotLoadMemories": "Није могуће учитати успомене", "couldNotLoadKnowledgeGraph": "Није могуће учитати граф знања" } diff --git a/app/lib/l10n/app_sv.arb b/app/lib/l10n/app_sv.arb index 9ef33f1beab..3de8fce08dd 100644 --- a/app/lib/l10n/app_sv.arb +++ b/app/lib/l10n/app_sv.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Lagringen på din Pendant är full och den är fortfarande i inspelningsläge, så det lagrade ljudet kan inte överföras. Tryck på Pendantens knapp för att stoppa inspelningen och synkronisera sedan igen.", "conversationsNotCapturedCount": "Inte fångat ({count})", "transcriptionNoAudio": "Transkrieringen tar inte emot ljud", + "chatBlockTask": "Uppgift", + "chatBlockGoal": "Mål", + "chatBlockConversation": "Konversation", + "chatBlockMemory": "Minne", + "chatBlockQuestion": "Fråga", + "chatBlockOpenInGoals": "Öppna i Mål", + "chatBlockOpenConversation": "Öppna konversation", + "chatBlockOpenInMemories": "Öppna i Minnen", + "chatBlockUnavailable": "Inte längre tillgänglig", + "chatBlockRecommendedNextSteps": "Rekommenderade nästa steg", "couldNotLoadMemories": "Kunde inte läsa in minnen", "couldNotLoadKnowledgeGraph": "Kunde inte läsa in kunskapsgrafen" } diff --git a/app/lib/l10n/app_ta.arb b/app/lib/l10n/app_ta.arb index 7ae10ccf713..1a5908ecfa1 100644 --- a/app/lib/l10n/app_ta.arb +++ b/app/lib/l10n/app_ta.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pendant-இன் சேமிப்பகம் நிரம்பிவிட்டது, அது இன்னும் பதிவு பயன்முறையில் உள்ளது, எனவே சேமிக்கப்பட்ட ஆடியோவை மாற்ற முடியாது. பதிவை நிறுத்த Pendant-இன் பொத்தானை அழுத்தி, பின்னர் மீண்டும் ஒத்திசைக்கவும்.", "conversationsNotCapturedCount": "பதிவு செய்யப்படவில்லை ({count})", "transcriptionNoAudio": "நகலெடுப்பு ஆடியோவைப் பெறவில்லை", + "chatBlockTask": "பணி", + "chatBlockGoal": "இலக்கு", + "chatBlockConversation": "உரையாடல்", + "chatBlockMemory": "நினைவு", + "chatBlockQuestion": "கேள்வி", + "chatBlockOpenInGoals": "இலக்குகளில் திறக்க", + "chatBlockOpenConversation": "உரையாடலைத் திறக்க", + "chatBlockOpenInMemories": "நினைவுகளில் திறக்க", + "chatBlockUnavailable": "இனி கிடைக்கவில்லை", + "chatBlockRecommendedNextSteps": "பரிந்துரைக்கப்பட்ட அடுத்த படிகள்", "couldNotLoadMemories": "நினைவுகளை ஏற்ற முடியவில்லை", "couldNotLoadKnowledgeGraph": "அறிவு வரைபடத்தை ஏற்ற முடியவில்லை" } diff --git a/app/lib/l10n/app_te.arb b/app/lib/l10n/app_te.arb index 2bbe44ca447..c1b08527239 100644 --- a/app/lib/l10n/app_te.arb +++ b/app/lib/l10n/app_te.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pendant నిల్వ నిండిపోయింది మరియు అది ఇంకా రికార్డింగ్ మోడ్‌లో ఉంది, కాబట్టి నిల్వ చేసిన ఆడియోను బదిలీ చేయడం సాధ్యం కాదు. రికార్డింగ్ ఆపడానికి Pendant బటన్‌ను నొక్కి, ఆపై మళ్లీ సింక్ చేయండి.", "conversationsNotCapturedCount": "రికార్డ్ కాలేదు ({count})", "transcriptionNoAudio": "ట్రాన్స్‌క్రిప్షన్ ఆడియో స్వీకరించడం లేదు", + "chatBlockTask": "పని", + "chatBlockGoal": "లక్ష్యం", + "chatBlockConversation": "సంభాషణ", + "chatBlockMemory": "జ్ఞాపకం", + "chatBlockQuestion": "ప్రశ్న", + "chatBlockOpenInGoals": "లక్ష్యాలలో తెరవండి", + "chatBlockOpenConversation": "సంభాషణను తెరవండి", + "chatBlockOpenInMemories": "జ్ఞాపకాలలో తెరవండి", + "chatBlockUnavailable": "ఇకపై అందుబాటులో లేదు", + "chatBlockRecommendedNextSteps": "సిఫార్సు చేసిన తదుపరి దశలు", "couldNotLoadMemories": "జ్ఞాపకాలను లోడ్ చేయలేకపోయాం", "couldNotLoadKnowledgeGraph": "నాలెడ్జ్ గ్రాఫ్‌ను లోడ్ చేయలేకపోయాం" } diff --git a/app/lib/l10n/app_th.arb b/app/lib/l10n/app_th.arb index 5363c427e28..cb442518021 100644 --- a/app/lib/l10n/app_th.arb +++ b/app/lib/l10n/app_th.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "พื้นที่จัดเก็บของ Pendant เต็มและยังอยู่ในโหมดบันทึกเสียง จึงไม่สามารถถ่ายโอนเสียงที่บันทึกไว้ได้ กดปุ่มของ Pendant เพื่อหยุดการบันทึก แล้วซิงค์อีกครั้ง", "conversationsNotCapturedCount": "ไม่ได้บันทึก ({count})", "transcriptionNoAudio": "การถอดเสียงไม่ได้รับเสียง", + "chatBlockTask": "งาน", + "chatBlockGoal": "เป้าหมาย", + "chatBlockConversation": "บทสนทนา", + "chatBlockMemory": "ความทรงจำ", + "chatBlockQuestion": "คำถาม", + "chatBlockOpenInGoals": "เปิดในเป้าหมาย", + "chatBlockOpenConversation": "เปิดบทสนทนา", + "chatBlockOpenInMemories": "เปิดในความทรงจำ", + "chatBlockUnavailable": "ไม่พร้อมใช้งานอีกต่อไป", + "chatBlockRecommendedNextSteps": "ขั้นตอนถัดไปที่แนะนำ", "couldNotLoadMemories": "ไม่สามารถโหลดความทรงจำได้", "couldNotLoadKnowledgeGraph": "ไม่สามารถโหลดกราฟความรู้ได้" } diff --git a/app/lib/l10n/app_tl.arb b/app/lib/l10n/app_tl.arb index a3ee1b2b591..baba01e4247 100644 --- a/app/lib/l10n/app_tl.arb +++ b/app/lib/l10n/app_tl.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Puno na ang storage ng Pendant at nasa recording mode pa rin ito, kaya hindi mailipat ang naka-imbak na audio. Pindutin ang button ng Pendant para ihinto ang pag-record, pagkatapos ay mag-sync muli.", "conversationsNotCapturedCount": "Hindi na-record ({count})", "transcriptionNoAudio": "Hindi tumatanggap ng audio ang transkripsyon", + "chatBlockTask": "Gawain", + "chatBlockGoal": "Layunin", + "chatBlockConversation": "Pag-uusap", + "chatBlockMemory": "Alaala", + "chatBlockQuestion": "Tanong", + "chatBlockOpenInGoals": "Buksan sa Mga Layunin", + "chatBlockOpenConversation": "Buksan ang pag-uusap", + "chatBlockOpenInMemories": "Buksan sa Mga Alaala", + "chatBlockUnavailable": "Hindi na available", + "chatBlockRecommendedNextSteps": "Mga inirerekomendang susunod na hakbang", "couldNotLoadMemories": "Hindi ma-load ang mga alaala", "couldNotLoadKnowledgeGraph": "Hindi ma-load ang knowledge graph" } diff --git a/app/lib/l10n/app_tr.arb b/app/lib/l10n/app_tr.arb index 097641cdfe5..3c4f117eb5f 100644 --- a/app/lib/l10n/app_tr.arb +++ b/app/lib/l10n/app_tr.arb @@ -3270,6 +3270,16 @@ "pendantFullSyncBlocked": "Pendant'ın depolama alanı dolu ve hâlâ kayıt modunda olduğu için kayıtlı ses aktarılamıyor. Kaydı durdurmak için Pendant'ın düğmesine basın, ardından yeniden senkronize edin.", "conversationsNotCapturedCount": "Kaydedilmedi ({count})", "transcriptionNoAudio": "Transkripsiyon ses almıyor", + "chatBlockTask": "Görev", + "chatBlockGoal": "Hedef", + "chatBlockConversation": "Konuşma", + "chatBlockMemory": "Anı", + "chatBlockQuestion": "Soru", + "chatBlockOpenInGoals": "Hedefler’de aç", + "chatBlockOpenConversation": "Konuşmayı aç", + "chatBlockOpenInMemories": "Anılar’da aç", + "chatBlockUnavailable": "Artık kullanılamıyor", + "chatBlockRecommendedNextSteps": "Önerilen sonraki adımlar", "couldNotLoadMemories": "Anılar yüklenemedi", "couldNotLoadKnowledgeGraph": "Bilgi grafiği yüklenemedi" } diff --git a/app/lib/l10n/app_uk.arb b/app/lib/l10n/app_uk.arb index 80e7c613975..2da416e411e 100644 --- a/app/lib/l10n/app_uk.arb +++ b/app/lib/l10n/app_uk.arb @@ -3235,6 +3235,16 @@ "pendantFullSyncBlocked": "Пам'ять Pendant заповнена, і він досі в режимі запису, тому збережене аудіо не можна передати. Натисніть кнопку Pendant, щоб зупинити запис, а потім синхронізуйте знову.", "conversationsNotCapturedCount": "Не записано ({count})", "transcriptionNoAudio": "Транскрипція не отримує аудіо", + "chatBlockTask": "Завдання", + "chatBlockGoal": "Ціль", + "chatBlockConversation": "Розмова", + "chatBlockMemory": "Спогад", + "chatBlockQuestion": "Питання", + "chatBlockOpenInGoals": "Відкрити в «Цілях»", + "chatBlockOpenConversation": "Відкрити розмову", + "chatBlockOpenInMemories": "Відкрити у «Спогадах»", + "chatBlockUnavailable": "Більше недоступно", + "chatBlockRecommendedNextSteps": "Рекомендовані наступні кроки", "couldNotLoadMemories": "Не вдалося завантажити спогади", "couldNotLoadKnowledgeGraph": "Не вдалося завантажити граф знань" } diff --git a/app/lib/l10n/app_ur.arb b/app/lib/l10n/app_ur.arb index cd7b557e61f..16e126aa04d 100644 --- a/app/lib/l10n/app_ur.arb +++ b/app/lib/l10n/app_ur.arb @@ -10801,6 +10801,16 @@ "pendantFullSyncBlocked": "Pendant کی اسٹوریج بھر گئی ہے اور یہ ابھی بھی ریکارڈنگ موڈ میں ہے، اس لیے محفوظ شدہ آڈیو منتقل نہیں کی جا سکتی۔ ریکارڈنگ روکنے کے لیے Pendant کا بٹن دبائیں، پھر دوبارہ مطابقت پذیری کریں۔", "conversationsNotCapturedCount": "ریکارڈ نہیں ہوا ({count})", "transcriptionNoAudio": "ٹرانسکرپشن آڈیو وصول نہیں کر رہی", + "chatBlockTask": "کام", + "chatBlockGoal": "ہدف", + "chatBlockConversation": "گفتگو", + "chatBlockMemory": "یاد", + "chatBlockQuestion": "سوال", + "chatBlockOpenInGoals": "اہداف میں کھولیں", + "chatBlockOpenConversation": "گفتگو کھولیں", + "chatBlockOpenInMemories": "یادوں میں کھولیں", + "chatBlockUnavailable": "اب دستیاب نہیں", + "chatBlockRecommendedNextSteps": "تجویز کردہ اگلے اقدامات", "couldNotLoadMemories": "یادیں لوڈ نہیں ہو سکیں", "couldNotLoadKnowledgeGraph": "نالج گراف لوڈ نہیں ہو سکا" } diff --git a/app/lib/l10n/app_vi.arb b/app/lib/l10n/app_vi.arb index 6c990865bfc..212a1bc606d 100644 --- a/app/lib/l10n/app_vi.arb +++ b/app/lib/l10n/app_vi.arb @@ -3240,6 +3240,16 @@ "pendantFullSyncBlocked": "Bộ nhớ của Pendant đã đầy và nó vẫn đang ở chế độ ghi âm, nên không thể chuyển âm thanh đã lưu. Nhấn nút của Pendant để dừng ghi âm, sau đó đồng bộ lại.", "conversationsNotCapturedCount": "Không được ghi âm ({count})", "transcriptionNoAudio": "Bản ghi âm không nhận được âm thanh", + "chatBlockTask": "Nhiệm vụ", + "chatBlockGoal": "Mục tiêu", + "chatBlockConversation": "Cuộc trò chuyện", + "chatBlockMemory": "Ký ức", + "chatBlockQuestion": "Câu hỏi", + "chatBlockOpenInGoals": "Mở trong Mục tiêu", + "chatBlockOpenConversation": "Mở cuộc trò chuyện", + "chatBlockOpenInMemories": "Mở trong Ký ức", + "chatBlockUnavailable": "Không còn khả dụng", + "chatBlockRecommendedNextSteps": "Các bước tiếp theo được đề xuất", "couldNotLoadMemories": "Không thể tải ký ức", "couldNotLoadKnowledgeGraph": "Không thể tải đồ thị tri thức" } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 5173dea177f..f9cfb6e0801 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -3257,6 +3257,16 @@ "pendantFullSyncBlocked": "Pendant 的存储空间已满,且仍处于录音模式,因此无法传输已存储的音频。请按下 Pendant 的按钮停止录音,然后重新同步。", "conversationsNotCapturedCount": "未记录 ({count})", "transcriptionNoAudio": "转录未接收到音频", + "chatBlockTask": "任务", + "chatBlockGoal": "目标", + "chatBlockConversation": "对话", + "chatBlockMemory": "记忆", + "chatBlockQuestion": "问题", + "chatBlockOpenInGoals": "在目标中打开", + "chatBlockOpenConversation": "打开对话", + "chatBlockOpenInMemories": "在记忆中打开", + "chatBlockUnavailable": "已不再可用", + "chatBlockRecommendedNextSteps": "建议的后续步骤", "couldNotLoadMemories": "无法加载回忆", "couldNotLoadKnowledgeGraph": "无法加载知识图谱" } diff --git a/app/lib/pages/chat/widgets/ai_message.dart b/app/lib/pages/chat/widgets/ai_message.dart index a72c6e4bf9b..0ad4f69c6bf 100644 --- a/app/lib/pages/chat/widgets/ai_message.dart +++ b/app/lib/pages/chat/widgets/ai_message.dart @@ -21,6 +21,7 @@ import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; import 'package:omi/models/chat_evidence_reference.dart'; import 'package:omi/pages/chat/widgets/chat_followup_chip.dart'; +import 'package:omi/pages/chat/widgets/content_blocks/chat_content_block_list.dart'; import 'package:omi/pages/chat/widgets/files_handler_widget.dart'; import 'package:omi/pages/chat/widgets/typing_indicator.dart'; import 'package:omi/pages/conversation_detail/conversation_detail_provider.dart'; @@ -287,8 +288,26 @@ Widget buildMessageWidget( bool showThinkingAfterText = false, Future Function(String id)? fetchConversation, }) { + final contentBlocks = ChatContentBlockList.hasRenderableBlocks(message) + ? ChatContentBlockList( + message: message, + sendMessage: sendMessage, + fetchConversation: fetchConversation, + ) + : null; + // A message whose text is only the fallback synthesized from its blocks has + // nothing to say that the components do not already show, so the components + // replace the body instead of repeating it. + final blocksReplaceBody = contentBlocks != null && + message.memories.isEmpty && + message.type != MessageType.daySummary && + !displayOptions && + message.textIsStructuredFallback; + final Widget messageWidget; - if (message.memories.isNotEmpty) { + if (blocksReplaceBody) { + messageWidget = contentBlocks; + } else if (message.memories.isNotEmpty) { messageWidget = MemoriesMessageWidget( showTypingIndicator: showTypingIndicator, messageMemories: message.memories, @@ -327,16 +346,21 @@ Widget buildMessageWidget( } final evidence = visibleSupplementalEvidence(message); + final appendBlocks = contentBlocks != null && !blocksReplaceBody; // Native content blocks. Both are additive chrome: an absent or malformed // block leaves the answer exactly as it renders today. final reviewCard = showTypingIndicator ? null : message.memoryReviewCard; final followUp = showTypingIndicator ? null : message.followUpQuestion; - if (evidence == null && reviewCard == null && followUp == null) return messageWidget; + if (evidence == null && !appendBlocks && reviewCard == null && followUp == null) return messageWidget; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ messageWidget, + if (appendBlocks) ...[ + const SizedBox(height: 8), + contentBlocks, + ], if (reviewCard != null) ...[ const SizedBox(height: 12), MemoryReviewCard( diff --git a/app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart b/app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart new file mode 100644 index 00000000000..5720f940449 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; + +import 'chat_block_chrome.dart'; + +/// Mobile counterparts of the desktop `AgentSpawnCard` / `AgentCompletionCard`. +/// +/// A background agent run is started and inspected on the desktop, so these +/// carry no "open" destination the way the goal and memory links do — a phone +/// cannot attach to that session. They are deliberately read-only: the point is +/// that a run the user started still reads as a run in the transcript on their +/// phone, instead of collapsing to the bare line "Agent started - ". +class AgentSpawnBlock extends StatelessWidget { + const AgentSpawnBlock({super.key, required this.block}); + + final AgentSpawnContentBlock block; + + @override + Widget build(BuildContext context) { + return _AgentRunCard( + icon: Icons.smart_toy_outlined, + label: 'Agent started', + title: block.title, + body: block.objective, + ); + } +} + +class AgentCompletionBlock extends StatelessWidget { + const AgentCompletionBlock({super.key, required this.block}); + + final AgentCompletionContentBlock block; + + /// The runtime's status vocabulary is open, so anything that is not a known + /// terminal failure reads as a completed run rather than an invented state. + bool get _failed { + final status = block.status.trim().toLowerCase(); + return status == 'failed' || status == 'error' || status == 'cancelled'; + } + + @override + Widget build(BuildContext context) { + return _AgentRunCard( + icon: _failed ? Icons.error_outline : Icons.check_circle_outline, + label: _failed ? 'Agent stopped' : 'Agent completed', + title: block.title, + body: block.output, + ); + } +} + +class _AgentRunCard extends StatelessWidget { + const _AgentRunCard({ + required this.icon, + required this.label, + required this.title, + required this.body, + }); + + final IconData icon; + final String label; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final trimmedTitle = title.trim(); + final trimmedBody = body.trim(); + + return ChatBlockCard( + semanticsLabel: trimmedTitle.isEmpty ? label : '$label: $trimmedTitle', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: icon, label: label), + if (trimmedTitle.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(trimmedTitle, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)), + ], + if (trimmedBody.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + trimmedBody, + maxLines: 6, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart b/app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart new file mode 100644 index 00000000000..d3e7f03a1aa --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; + +/// Shared visual chrome for chat content-block components. +/// +/// Deliberately mirrors [ChatEvidenceReferenceCard]'s paddings, radius, and +/// colors so structured blocks read as one family inside the transcript. +class ChatBlockCard extends StatelessWidget { + const ChatBlockCard({ + super.key, + required this.child, + this.onTap, + this.semanticsLabel, + }); + + final Widget child; + final VoidCallback? onTap; + final String? semanticsLabel; + + static const double radius = 10; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final card = Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.55)), + ), + child: child, + ); + + final content = onTap == null + ? card + : InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(radius), + child: card, + ); + + if (semanticsLabel == null) return content; + return Semantics( + container: true, + label: semanticsLabel, + button: onTap != null, + enabled: onTap != null, + child: content, + ); + } +} + +/// Small caption row naming the block's entity ("Task", "Goal", ...). +class ChatBlockEyebrow extends StatelessWidget { + const ChatBlockEyebrow({super.key, required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Terminal state for a block whose entity cannot be resolved any more. +class ChatBlockUnavailable extends StatelessWidget { + const ChatBlockUnavailable({ + super.key, + required this.icon, + required this.label, + required this.message, + }); + + final IconData icon; + final String label; + final String message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + semanticsLabel: '$label: $message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: icon, label: label), + const SizedBox(height: 6), + Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} + +/// Placeholder while the owning store is still hydrating the entity. +class ChatBlockLoading extends StatelessWidget { + const ChatBlockLoading({ + super.key, + required this.icon, + required this.label, + required this.message, + }); + + final IconData icon; + final String label; + final String message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + semanticsLabel: '$label: $message', + child: Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} + +/// Summary + single destination action, shared by the goal/capture/conversation +/// /memory link blocks. +class ChatBlockLinkCard extends StatelessWidget { + const ChatBlockLinkCard({ + super.key, + required this.icon, + required this.label, + required this.summary, + required this.actionTitle, + required this.actionKey, + required this.onAction, + this.isOpening = false, + this.footer, + }); + + final IconData icon; + final String label; + final String summary; + final String actionTitle; + final Key actionKey; + final VoidCallback? onAction; + final bool isOpening; + final Widget? footer; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: icon, label: label), + const SizedBox(height: 6), + Text(summary, style: Theme.of(context).textTheme.bodyMedium), + if (footer != null) ...[const SizedBox(height: 8), footer!], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: actionKey, + onPressed: isOpening ? null : onAction, + icon: isOpening + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onSurfaceVariant), + ) + : const Icon(Icons.open_in_new, size: 16), + label: Text(actionTitle), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart b/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart new file mode 100644 index 00000000000..0b16535e163 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/backend/schema/message.dart'; + +import 'agent_run_blocks.dart'; +import 'conversation_link_blocks.dart'; +import 'discovery_card_block.dart'; +import 'goal_link_block.dart'; +import 'memory_link_block.dart'; +import 'question_card_block.dart'; +import 'task_card_block.dart'; + +/// Renders the interactable components for a message's `content_blocks`. +/// +/// Every block the desktop transcript draws as its own control has a component +/// here, so a turn reads the same on both clients. text, thinking, toolCall, +/// citation and unknown types are covered by the message body (or its +/// synthesized fallback text) and deliberately render nothing extra — but they +/// never hide the message. +class ChatContentBlockList extends StatelessWidget { + const ChatContentBlockList({ + super.key, + required this.message, + required this.sendMessage, + this.fetchConversation, + }); + + final ServerMessage message; + final void Function(String) sendMessage; + final Future<ServerConversation?> Function(String id)? fetchConversation; + + /// True when at least one block in [message] has an interactable component. + static bool hasRenderableBlocks(ServerMessage message) { + return message.typedContentBlocks.any(_isRenderable); + } + + static bool _isRenderable(ChatContentBlock block) { + return block is TaskCardContentBlock || + block is GoalLinkContentBlock || + block is CaptureLinkContentBlock || + block is ConversationLinkContentBlock || + block is MemoryLinkContentBlock || + block is QuestionCardContentBlock || + block is DiscoveryCardContentBlock || + block is AgentSpawnContentBlock || + block is AgentCompletionContentBlock; + } + + Widget? _build(ChatContentBlock block) { + switch (block) { + case TaskCardContentBlock(): + return TaskCardBlock(block: block); + case GoalLinkContentBlock(): + return GoalLinkBlock(block: block); + case CaptureLinkContentBlock(): + return CaptureLinkBlock(block: block, fetchConversation: fetchConversation); + case ConversationLinkContentBlock(): + return ConversationLinkBlock(block: block, fetchConversation: fetchConversation); + case MemoryLinkContentBlock(): + return MemoryLinkBlock(block: block); + case QuestionCardContentBlock(): + return QuestionCardBlock(block: block, sendMessage: sendMessage); + case DiscoveryCardContentBlock(): + return DiscoveryCardBlock(block: block); + case AgentSpawnContentBlock(): + return AgentSpawnBlock(block: block); + case AgentCompletionContentBlock(): + return AgentCompletionBlock(block: block); + case TextContentBlock(): + case ThinkingContentBlock(): + case ToolCallContentBlock(): + case CitationContentBlock(): + case UnknownContentBlock(): + return null; + } + } + + @override + Widget build(BuildContext context) { + final children = <Widget>[]; + for (final block in message.typedContentBlocks) { + final widget = _build(block); + if (widget == null) continue; + if (children.isNotEmpty) children.add(const SizedBox(height: 8)); + children.add(widget); + } + if (children.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart b/app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart new file mode 100644 index 00000000000..957c5de0306 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/conversations.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/pages/chat/widgets/ai_message.dart' show resolveChatCitationConversation; +import 'package:omi/pages/conversation_detail/conversation_detail_provider.dart'; +import 'package:omi/pages/conversation_detail/page.dart'; +import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Opens the conversation behind a `captureLink` / `conversationLink` block. +/// +/// Reuses the citation preamble already shipped in chat: resolve from the +/// grouped provider map, fall back to a fetch by id, then push +/// [ConversationDetailPage]. Returns false when the conversation is gone so the +/// caller can show the unavailable state instead of a dead end. +Future<bool> openChatBlockConversation( + BuildContext context, { + required String conversationId, + Future<ServerConversation?> Function(String id)? fetchConversation, +}) async { + final conversations = Provider.of<ConversationProvider>(context, listen: false); + final fetch = fetchConversation ?? getConversationById; + final conversation = await resolveChatCitationConversation( + conversations: conversations, + conversationId: conversationId, + fetchConversation: fetch, + ); + if (!context.mounted) return false; + if (conversation == null) return false; + + var located = conversations.getConversationDateAndIndexById(conversation.id); + var date = located?.$1; + if (date == null) { + (_, date) = conversations.addConversationWithDateGrouped(conversation); + } + + context.read<ConversationDetailProvider>().updateConversation(conversation.id, date); + await Navigator.of(context).push( + MaterialPageRoute(builder: (c) => ConversationDetailPage(conversation: conversation)), + ); + return true; +} + +/// Renders a `captureLink` block: a pointer back to the capture that produced +/// the answer. +class CaptureLinkBlock extends StatefulWidget { + const CaptureLinkBlock({super.key, required this.block, this.fetchConversation}); + + final CaptureLinkContentBlock block; + final Future<ServerConversation?> Function(String id)? fetchConversation; + + @override + State<CaptureLinkBlock> createState() => _CaptureLinkBlockState(); +} + +class _CaptureLinkBlockState extends State<CaptureLinkBlock> { + bool _isOpening = false; + bool _isUnavailable = false; + + Future<void> _open() async { + if (_isOpening) return; + setState(() => _isOpening = true); + final opened = await openChatBlockConversation( + context, + conversationId: widget.block.conversationId, + fetchConversation: widget.fetchConversation, + ); + if (!mounted) return; + setState(() { + _isOpening = false; + _isUnavailable = !opened; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + if (_isUnavailable) { + return ChatBlockUnavailable( + key: Key('chat-block-captureLink-${widget.block.id}-unavailable'), + icon: Icons.graphic_eq, + label: l10n.chatBlockConversation, + message: l10n.chatBlockUnavailable, + ); + } + return ChatBlockLinkCard( + key: Key('chat-block-captureLink-${widget.block.id}'), + icon: Icons.graphic_eq, + label: l10n.chatBlockConversation, + summary: widget.block.summary, + actionTitle: l10n.chatBlockOpenConversation, + actionKey: Key('chat-block-captureLink-${widget.block.id}-open'), + isOpening: _isOpening, + onAction: _open, + ); + } +} + +/// Renders a `conversationLink` block: the conversation plus the action items +/// it recommends. Recommended items are plain rows — mobile creates tasks from +/// the tasks surface, so this block never mutates anything. +class ConversationLinkBlock extends StatefulWidget { + const ConversationLinkBlock({super.key, required this.block, this.fetchConversation}); + + final ConversationLinkContentBlock block; + final Future<ServerConversation?> Function(String id)? fetchConversation; + + @override + State<ConversationLinkBlock> createState() => _ConversationLinkBlockState(); +} + +class _ConversationLinkBlockState extends State<ConversationLinkBlock> { + bool _isOpening = false; + bool _isUnavailable = false; + + Future<void> _open() async { + if (_isOpening) return; + setState(() => _isOpening = true); + final opened = await openChatBlockConversation( + context, + conversationId: widget.block.conversationId, + fetchConversation: widget.fetchConversation, + ); + if (!mounted) return; + setState(() { + _isOpening = false; + _isUnavailable = !opened; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + if (_isUnavailable) { + return ChatBlockUnavailable( + key: Key('chat-block-conversationLink-${widget.block.id}-unavailable'), + icon: Icons.subject, + label: l10n.chatBlockConversation, + message: l10n.chatBlockUnavailable, + ); + } + + final colorScheme = Theme.of(context).colorScheme; + final items = widget.block.recommendedActionItems; + return ChatBlockLinkCard( + key: Key('chat-block-conversationLink-${widget.block.id}'), + icon: Icons.subject, + label: l10n.chatBlockConversation, + summary: widget.block.summary, + actionTitle: l10n.chatBlockOpenConversation, + actionKey: Key('chat-block-conversationLink-${widget.block.id}-open'), + isOpening: _isOpening, + onAction: _open, + footer: items.isEmpty + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.chatBlockRecommendedNextSteps, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + for (final item in items) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.circle, size: 5, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + item.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart b/app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart new file mode 100644 index 00000000000..c5e54dfeb90 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; + +import 'chat_block_chrome.dart'; + +/// Mobile counterpart of the desktop `DiscoveryCard`. +/// +/// The block carries a short summary and the full text behind it. Without a +/// component the transcript showed only the synthesized "Discovery - <title> - +/// <summary>" line, which loses the body entirely; this keeps the body one tap +/// away rather than dropping it. +class DiscoveryCardBlock extends StatefulWidget { + const DiscoveryCardBlock({super.key, required this.block}); + + final DiscoveryCardContentBlock block; + + @override + State<DiscoveryCardBlock> createState() => _DiscoveryCardBlockState(); +} + +class _DiscoveryCardBlockState extends State<DiscoveryCardBlock> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final summary = widget.block.summary.trim(); + final fullText = widget.block.fullText.trim(); + // Expanding is only worth offering when there is more than the summary. + final hasMore = fullText.isNotEmpty && fullText != summary; + final body = _isExpanded && hasMore ? fullText : summary; + + return ChatBlockCard( + onTap: hasMore ? () => setState(() => _isExpanded = !_isExpanded) : null, + semanticsLabel: 'Discovery: ${widget.block.title}', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const ChatBlockEyebrow(icon: Icons.auto_awesome_outlined, label: 'Discovery'), + const SizedBox(height: 6), + if (widget.block.title.trim().isNotEmpty) + Text( + widget.block.title, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + if (body.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(body, style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + if (hasMore) ...[ + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isExpanded ? 'Show less' : 'Show more', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart b/app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart new file mode 100644 index 00000000000..7680081d20b --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/goals.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/providers/goals_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `goalLink` block. +/// +/// Mobile has no goal detail route (goals live as a flat list behind +/// [GoalsProvider] and are rendered inline by the goals widget), so the block +/// opens a bottom sheet with the resolved goal's title and progress instead of +/// inventing a navigation destination. A goal that is not in the loaded list +/// renders the unavailable state rather than a dead button. +class GoalLinkBlock extends StatelessWidget { + const GoalLinkBlock({super.key, required this.block}); + + final GoalLinkContentBlock block; + + Goal? _resolve(GoalsProvider provider) { + for (final goal in provider.goals) { + if (goal.id == block.goalId) return goal; + } + return null; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Consumer<GoalsProvider>( + builder: (context, provider, _) { + final goal = _resolve(provider); + if (goal == null && !provider.isLoading) { + return ChatBlockUnavailable( + key: Key('chat-block-goalLink-${block.id}-unavailable'), + icon: Icons.flag_outlined, + label: l10n.chatBlockGoal, + message: l10n.chatBlockUnavailable, + ); + } + + return ChatBlockLinkCard( + key: Key('chat-block-goalLink-${block.id}'), + icon: Icons.flag_outlined, + label: l10n.chatBlockGoal, + summary: block.summary, + actionTitle: l10n.chatBlockOpenInGoals, + actionKey: Key('chat-block-goalLink-${block.id}-open'), + isOpening: goal == null, + onAction: goal == null ? null : () => _showGoalSheet(context, goal), + ); + }, + ); + } + + void _showGoalSheet(BuildContext context, Goal goal) { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + final unit = goal.unit?.trim(); + final progress = '${_format(goal.currentValue)} / ${_format(goal.targetValue)}' + '${unit == null || unit.isEmpty ? '' : ' $unit'}'; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChatBlockEyebrow(icon: Icons.flag_outlined, label: sheetContext.l10n.chatBlockGoal), + const SizedBox(height: 8), + Text(goal.title, style: Theme.of(sheetContext).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + progress, + key: Key('chat-block-goalLink-${block.id}-progress'), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ); + }, + ); + } + + static String _format(double value) { + return value == value.roundToDouble() ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart b/app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart new file mode 100644 index 00000000000..f91924006fa --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/memory.dart'; +import 'package:omi/pages/memories/widgets/memory_dialog.dart'; +import 'package:omi/providers/memories_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `memoryLink` block. +/// +/// Memories are loaded as a list, so the block resolves the id against +/// [MemoriesProvider] and opens the existing memory sheet. An id that is not in +/// the loaded list renders the unavailable state. +class MemoryLinkBlock extends StatelessWidget { + const MemoryLinkBlock({super.key, required this.block}); + + final MemoryLinkContentBlock block; + + Memory? _resolve(MemoriesProvider provider) { + for (final memory in provider.memories) { + if (memory.id == block.memoryId) return memory; + } + return null; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Consumer<MemoriesProvider>( + builder: (context, provider, _) { + final memory = _resolve(provider); + if (memory == null && !provider.loading) { + return ChatBlockUnavailable( + key: Key('chat-block-memoryLink-${block.id}-unavailable'), + icon: Icons.psychology_outlined, + label: l10n.chatBlockMemory, + message: l10n.chatBlockUnavailable, + ); + } + + return ChatBlockLinkCard( + key: Key('chat-block-memoryLink-${block.id}'), + icon: Icons.psychology_outlined, + label: l10n.chatBlockMemory, + summary: block.summary, + actionTitle: l10n.chatBlockOpenInMemories, + actionKey: Key('chat-block-memoryLink-${block.id}-open'), + isOpening: memory == null, + onAction: memory == null ? null : () => showMemoryDialog(context, provider, memory: memory), + ); + }, + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/question_card_block.dart b/app/lib/pages/chat/widgets/content_blocks/question_card_block.dart new file mode 100644 index 00000000000..0a92f811230 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/question_card_block.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `questionCard` block: the question plus its prepared answers. +/// +/// Tapping an option sends its `preparedAnswer` as a normal chat message — the +/// same path the initial suggestion chips already use — so the runtime stays +/// authoritative for what an answer means. A deferral option is not special: +/// it sends its own prepared answer. Once `selectedOptionId` is set the card +/// keeps the question readable and shows only the chosen option, disabled, so +/// no stale chip ever looks tappable. +class QuestionCardBlock extends StatelessWidget { + const QuestionCardBlock({super.key, required this.block, required this.sendMessage}); + + final QuestionCardContentBlock block; + final void Function(String) sendMessage; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + final selectedId = block.selectedOptionId; + final answered = selectedId != null; + final options = answered + ? block.options.where((option) => option.optionId == selectedId).toList(growable: false) + : block.options; + + return ChatBlockCard( + key: Key('chat-block-questionCard-${block.id}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: Icons.help_outline, label: l10n.chatBlockQuestion), + const SizedBox(height: 6), + Text(block.text, style: Theme.of(context).textTheme.bodyMedium), + if (options.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final option in options) + OutlinedButton( + key: Key('chat-block-questionCard-${block.id}-option-${option.optionId}'), + onPressed: answered ? null : () => sendMessage(option.preparedAnswer), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: colorScheme.onSurface, + side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.55)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: Text(option.label, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/task_card_block.dart b/app/lib/pages/chat/widgets/content_blocks/task_card_block.dart new file mode 100644 index 00000000000..86be4c2939e --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/task_card_block.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/schema/action_item.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/providers/action_items_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `taskCard` block as a live, toggleable task row. +/// +/// The tasks API is list-only (there is no fetch-by-id), so the card resolves +/// against the loaded [ActionItemsProvider] list and mirrors the macOS states: +/// loading while the list is still hydrating, unavailable once it has loaded +/// without the task. +class TaskCardBlock extends StatefulWidget { + const TaskCardBlock({super.key, required this.block}); + + final TaskCardContentBlock block; + + @override + State<TaskCardBlock> createState() => _TaskCardBlockState(); +} + +class _TaskCardBlockState extends State<TaskCardBlock> { + bool _isToggling = false; + bool _hydrated = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await context.read<ActionItemsProvider>().ensureLoaded(); + if (mounted) setState(() => _hydrated = true); + }); + } + + ActionItemWithMetadata? _resolve(ActionItemsProvider provider) { + for (final item in provider.actionItems) { + if (item.id == widget.block.taskId || item.taskId == widget.block.taskId) return item; + } + return null; + } + + Future<void> _toggle(ActionItemsProvider provider, ActionItemWithMetadata item) async { + if (_isToggling) return; + setState(() => _isToggling = true); + try { + await provider.updateActionItemState(item, !item.completed); + } finally { + if (mounted) setState(() => _isToggling = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Consumer<ActionItemsProvider>( + builder: (context, provider, _) { + final item = _resolve(provider); + if (item == null) { + if (!_hydrated || provider.isLoading) { + return ChatBlockLoading( + key: Key('chat-block-taskCard-${widget.block.id}-loading'), + icon: Icons.checklist, + label: l10n.chatBlockTask, + message: l10n.loading, + ); + } + return ChatBlockUnavailable( + key: Key('chat-block-taskCard-${widget.block.id}-unavailable'), + icon: Icons.checklist, + label: l10n.chatBlockTask, + message: l10n.chatBlockUnavailable, + ); + } + + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + key: Key('chat-block-taskCard-${widget.block.id}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: Icons.checklist, label: l10n.chatBlockTask), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + key: Key('chat-block-taskCard-${widget.block.id}-toggle'), + onPressed: _isToggling ? null : () => _toggle(provider, item), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon( + item.completed ? Icons.check_circle : Icons.circle_outlined, + size: 20, + color: item.completed ? Colors.green : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: item.completed ? colorScheme.onSurfaceVariant : colorScheme.onSurface, + decoration: item.completed ? TextDecoration.lineThrough : null, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index 0a6688f78ca..6514b48d6f4 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -362,7 +362,7 @@ class MessageProvider extends ChangeNotifier { } messages = await getMessagesFromServer(dropdownSelected: dropdownSelected); if (messages.isEmpty) { - messages = ServerMessage.visibleOnMobile(SharedPreferencesUtil().cachedMessages); + messages = List<ServerMessage>.from(SharedPreferencesUtil().cachedMessages); } else { SharedPreferencesUtil().cachedMessages = messages; setHasCachedMessages(true); @@ -375,7 +375,7 @@ class MessageProvider extends ChangeNotifier { void setMessagesFromCache() { if (SharedPreferencesUtil().cachedMessages.isNotEmpty) { setHasCachedMessages(true); - messages = ServerMessage.visibleOnMobile(SharedPreferencesUtil().cachedMessages); + messages = List<ServerMessage>.from(SharedPreferencesUtil().cachedMessages); messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); } notifyListeners(); @@ -393,7 +393,7 @@ class MessageProvider extends ChangeNotifier { firstTimeLoadingText = l10n?.msgLearningMemories ?? 'Learning from your memories...'; notifyListeners(); } - messages = ServerMessage.visibleOnMobile(mes); + messages = List<ServerMessage>.from(mes); messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); setLoadingMessages(false); notifyListeners(); @@ -411,7 +411,7 @@ class MessageProvider extends ChangeNotifier { Future clearChat() async { setClearingChat(true); var mes = await clearChatServer(appId: appProvider?.selectedChatAppId); - messages = ServerMessage.visibleOnMobile(mes); + messages = List<ServerMessage>.from(mes); messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); setClearingChat(false); notifyListeners(); @@ -450,7 +450,6 @@ class MessageProvider extends ChangeNotifier { } void addMessage(ServerMessage message) { - if (message.hideFromMobileChat) return; if (messages.firstWhereOrNull((m) => m.id == message.id) != null) { return; } diff --git a/app/test/unit/chat_content_block_decode_test.dart b/app/test/unit/chat_content_block_decode_test.dart new file mode 100644 index 00000000000..287add9f033 --- /dev/null +++ b/app/test/unit/chat_content_block_decode_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; + +void main() { + ChatContentBlock? decode(Map<String, dynamic> raw) => ChatContentBlock.tryDecode(raw); + + group('camelCase wire (desktop/agent dialect)', () { + test('decodes every interactable block type', () { + final blocks = ChatContentBlock.decodeList([ + {'type': 'taskCard', 'id': 'b1', 'taskId': 'task-1'}, + {'type': 'goalLink', 'id': 'b2', 'goalId': 'goal-1', 'summary': 'Ship the release'}, + { + 'type': 'captureLink', + 'id': 'b3', + 'conversationId': 'conversation-1', + 'summary': 'Standup', + 'momentTimestampMs': 1234, + }, + { + 'type': 'conversationLink', + 'id': 'b4', + 'conversationId': 'conversation-2', + 'summary': 'Weekly planning', + 'recommendedActionItems': [ + {'description': 'Draft the plan', 'taskId': 'task-9'}, + {'description': ' '}, + ], + }, + {'type': 'memoryLink', 'id': 'b5', 'memoryId': 'memory-1', 'summary': 'Prefers dark mode'}, + { + 'type': 'questionCard', + 'id': 'b6', + 'questionId': 'question-1', + 'text': 'What next?', + 'subject': {'kind': 'goal', 'id': 'goal-1'}, + 'options': [ + {'optionId': 'ship', 'label': 'Ship it', 'preparedAnswer': 'Ship it today'}, + {'optionId': 'later', 'label': 'Later', 'preparedAnswer': 'Ask me later', 'defer': true}, + ], + 'selectedOptionId': 'ship', + }, + ]); + + expect(blocks, hasLength(6)); + expect((blocks[0] as TaskCardContentBlock).taskId, 'task-1'); + expect((blocks[1] as GoalLinkContentBlock).goalId, 'goal-1'); + + final capture = blocks[2] as CaptureLinkContentBlock; + expect(capture.conversationId, 'conversation-1'); + expect(capture.momentTimestampMs, 1234); + + final conversation = blocks[3] as ConversationLinkContentBlock; + expect(conversation.recommendedActionItems, hasLength(1)); + expect(conversation.recommendedActionItems.single.taskId, 'task-9'); + + expect((blocks[4] as MemoryLinkContentBlock).memoryId, 'memory-1'); + + final question = blocks[5] as QuestionCardContentBlock; + expect(question.subjectKind, 'goal'); + expect(question.selectedOptionId, 'ship'); + expect(question.options, hasLength(2)); + expect(question.options.last.isDeferral, isTrue); + expect(question.options.last.preparedAnswer, 'Ask me later'); + }); + + test('decodes the non-interactable types without dropping them', () { + expect(decode({'type': 'text', 'id': 'b1', 'text': 'hi'}), isA<TextContentBlock>()); + expect(decode({'type': 'thinking', 'id': 'b2', 'text': 'hmm'}), isA<ThinkingContentBlock>()); + expect( + decode({'type': 'toolCall', 'id': 'b3', 'name': 'search', 'status': 'running'}), + isA<ToolCallContentBlock>(), + ); + expect( + decode({'type': 'discoveryCard', 'id': 'b4', 'title': 'T', 'summary': 'S', 'fullText': 'F'}), + isA<DiscoveryCardContentBlock>(), + ); + expect( + decode({'type': 'citation', 'id': 'b5', 'ordinal': 1, 'kind': 'conversation', 'sourceId': 'c1'}), + isA<CitationContentBlock>(), + ); + expect( + decode({'type': 'agentSpawn', 'id': 'b6', 'sessionId': 's1', 'runId': 'r1'}), + isA<AgentSpawnContentBlock>(), + ); + expect(decode({'type': 'agentCompletion', 'id': 'b7'}), isA<AgentCompletionContentBlock>()); + }); + }); + + group('snake_case wire (validated chat-first specs)', () { + test('reads every renamed field', () { + final blocks = ChatContentBlock.decodeList([ + {'type': 'task_card', 'id': 'b1', 'task_id': 'task-1'}, + {'type': 'goal_link', 'id': 'b2', 'goal_id': 'goal-1', 'summary': 'Ship'}, + { + 'type': 'capture_link', + 'id': 'b3', + 'conversation_id': 'conversation-1', + 'summary': 'Standup', + 'moment_timestamp_ms': 99, + }, + { + 'type': 'conversation_link', + 'id': 'b4', + 'conversation_id': 'conversation-2', + 'summary': 'Planning', + 'recommended_action_items': [ + {'description': 'Draft', 'task_id': 'task-9'}, + ], + }, + {'type': 'memory_link', 'id': 'b5', 'memory_id': 'memory-1', 'summary': 'Dark mode'}, + { + 'type': 'question_card', + 'id': 'b6', + 'question_id': 'question-1', + 'text': 'What next?', + 'subject': {'kind': 'task', 'id': 'task-1'}, + 'options': [ + {'option_id': 'ship', 'label': 'Ship it', 'prepared_answer': 'Ship it today'}, + ], + 'selected_option_id': 'ship', + }, + ]); + + expect(blocks, hasLength(6)); + expect((blocks[0] as TaskCardContentBlock).taskId, 'task-1'); + expect((blocks[1] as GoalLinkContentBlock).goalId, 'goal-1'); + expect((blocks[2] as CaptureLinkContentBlock).momentTimestampMs, 99); + expect((blocks[3] as ConversationLinkContentBlock).recommendedActionItems.single.taskId, 'task-9'); + expect((blocks[4] as MemoryLinkContentBlock).memoryId, 'memory-1'); + + final question = blocks[5] as QuestionCardContentBlock; + expect(question.subjectId, 'task-1'); + expect(question.selectedOptionId, 'ship'); + expect(question.options.single.preparedAnswer, 'Ship it today'); + }); + }); + + group('required fields', () { + test('drops blocks that the macOS codec would also drop', () { + expect(decode({'type': 'taskCard', 'id': 'b1'}), isNull); + expect(decode({'type': 'taskCard', 'taskId': 'task-1'}), isNull, reason: 'missing id'); + expect(decode({'id': 'b1', 'taskId': 'task-1'}), isNull, reason: 'missing type'); + expect(decode({'type': 'goalLink', 'id': 'b1', 'goalId': 'goal-1'}), isNull, reason: 'missing summary'); + expect(decode({'type': 'goalLink', 'id': 'b1', 'summary': 'Ship'}), isNull, reason: 'missing goalId'); + expect(decode({'type': 'memoryLink', 'id': 'b1', 'summary': 'Ship'}), isNull); + expect(decode({'type': 'conversationLink', 'id': 'b1', 'summary': 'Ship'}), isNull); + expect(decode({'type': 'toolCall', 'id': 'b1', 'status': 'running'}), isNull, reason: 'missing name'); + expect( + decode({ + 'type': 'questionCard', + 'id': 'b1', + 'questionId': 'q1', + 'text': 'What next?', + 'subject': {'kind': 'goal', 'id': 'goal-1'}, + 'options': <Map<String, dynamic>>[], + }), + isNull, + reason: 'no usable options', + ); + expect( + decode({ + 'type': 'questionCard', + 'id': 'b1', + 'questionId': 'q1', + 'text': 'What next?', + 'options': [ + {'optionId': 'a', 'label': 'A', 'preparedAnswer': 'A'}, + ], + }), + isNull, + reason: 'missing subject', + ); + }); + + test('drops malformed entries but keeps the rest of the list', () { + final blocks = ChatContentBlock.decodeList([ + {'type': 'taskCard', 'id': 'b1'}, + {'type': 'taskCard', 'id': 'b2', 'taskId': 'task-2'}, + ]); + expect(blocks, hasLength(1)); + expect((blocks.single as TaskCardContentBlock).taskId, 'task-2'); + }); + + test('falls back to the option label when no prepared answer is sent', () { + final question = decode({ + 'type': 'questionCard', + 'id': 'b1', + 'questionId': 'q1', + 'text': 'What next?', + 'subject': {'kind': 'cold_start', 'id': 'seq-1'}, + 'options': [ + {'optionId': 'a', 'label': 'Ship it'}, + ], + })! as QuestionCardContentBlock; + expect(question.options.single.preparedAnswer, 'Ship it'); + }); + }); + + test('an unknown type becomes an unknown block instead of being dropped', () { + final block = decode({'type': 'somethingNew', 'id': 'b1', 'title': 'Future'}); + expect(block, isA<UnknownContentBlock>()); + expect(block!.type, 'somethingNew'); + expect((block as UnknownContentBlock).raw['title'], 'Future'); + }); +} diff --git a/app/test/unit/chat_content_block_parity_test.dart b/app/test/unit/chat_content_block_parity_test.dart new file mode 100644 index 00000000000..6e48776d7b1 --- /dev/null +++ b/app/test/unit/chat_content_block_parity_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/message.dart'; +import 'package:omi/pages/chat/widgets/content_blocks/chat_content_block_list.dart'; + +/// The desktop transcript draws nine block kinds as their own control. A kind +/// the phone cannot draw degrades to one synthesized line — "Discovery - <title>", +/// "Agent started - <title>" — so the same turn reads as a card on one client +/// and a stray label on the other. These pin the parity in both directions. +void main() { + ServerMessage messageWith(List<Map<String, dynamic>> blocks, {String text = ''}) { + return ServerMessage.fromJson({ + 'id': 'message-1', + 'created_at': '2026-09-02T12:00:00Z', + 'text': text, + 'sender': 'ai', + 'type': 'text', + 'content_blocks': blocks, + }); + } + + /// Every kind the desktop renders as a control, with the payload the runtime + /// sends for it. + const desktopRenderedBlocks = <String, Map<String, dynamic>>{ + 'taskCard': {'id': 'b-task', 'type': 'taskCard', 'taskId': 'task-1'}, + 'goalLink': {'id': 'b-goal', 'type': 'goalLink', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, + 'captureLink': {'id': 'b-capture', 'type': 'captureLink', 'conversationId': 'conversation-1', 'summary': 'Standup'}, + 'conversationLink': { + 'id': 'b-conversation', + 'type': 'conversationLink', + 'conversationId': 'conversation-2', + 'summary': 'Founders explore AI memory', + }, + 'memoryLink': {'id': 'b-memory', 'type': 'memoryLink', 'memoryId': 'memory-1', 'summary': 'Prefers dark mode'}, + 'questionCard': { + 'id': 'b-question', + 'type': 'questionCard', + 'questionId': 'question-1', + 'text': 'Which one first?', + 'subject': {'kind': 'task', 'id': 'task-1'}, + 'options': [ + {'optionId': 'option-1', 'label': 'The hackathon'}, + ], + }, + 'discoveryCard': { + 'id': 'b-discovery', + 'type': 'discoveryCard', + 'title': 'You ship on Fridays', + 'summary': 'Nine of your last ten releases landed on a Friday.', + 'fullText': 'Nine of your last ten releases landed on a Friday afternoon.', + }, + 'agentSpawn': { + 'id': 'b-spawn', + 'type': 'agentSpawn', + 'sessionId': 'session-1', + 'runId': 'run-1', + 'title': 'Fix the scroll', + 'objective': 'Keep the transcript pinned while streaming', + }, + 'agentCompletion': { + 'id': 'b-completion', + 'type': 'agentCompletion', + 'sessionId': 'session-1', + 'runId': 'run-1', + 'title': 'Fix the scroll', + 'output': 'Reply no longer collapses when it settles', + 'status': 'completed', + }, + }; + + test('every block the desktop draws as a control has a mobile component', () { + for (final entry in desktopRenderedBlocks.entries) { + expect( + ChatContentBlockList.hasRenderableBlocks(messageWith([entry.value])), + isTrue, + reason: '${entry.key} renders as a card on desktop and must not degrade to a label here', + ); + } + }); + + test('a body that is only the blocks own projection is left to the components', () { + final message = messageWith([ + desktopRenderedBlocks['goalLink']!, + desktopRenderedBlocks['taskCard']!, + desktopRenderedBlocks['taskCard']!, + ]); + + // What the runtime synthesizes for an unaware client, verbatim. + expect(message.text, 'Goal - Make Omi Great Again\nTask\nTask'); + expect(message.textIsStructuredFallback, isTrue); + }); + + test('prose the model actually wrote survives alongside its cards', () { + final message = messageWith( + [desktopRenderedBlocks['taskCard']!], + text: 'Start with the hackathon — the deadline is closest.', + ); + + expect(message.textIsStructuredFallback, isFalse); + }); + + test('blocks with no component still leave the body alone', () { + final message = messageWith([ + {'id': 'b-thinking', 'type': 'thinking', 'text': 'weighing the options'}, + ], text: 'Here is what I would do.'); + + expect(ChatContentBlockList.hasRenderableBlocks(message), isFalse); + expect(message.textIsStructuredFallback, isFalse); + }); +} diff --git a/app/test/unit/memory_review_block_test.dart b/app/test/unit/memory_review_block_test.dart index cf8363968c8..e94d0fa6d83 100644 --- a/app/test/unit/memory_review_block_test.dart +++ b/app/test/unit/memory_review_block_test.dart @@ -111,8 +111,11 @@ void main() { expect(message.followUpQuestion, 'Want the rest of what she said?'); expect(message.text, 'You met Priya on Tuesday.'); - // followUp is not desktop-only chrome; the answer still shows on mobile. - expect(message.hideFromMobileChat, isFalse); + // Mobile renders the desktop chat-first blocks now rather than hiding the + // messages that carry them, so `hideFromMobileChat` is gone. What this test + // was protecting — the answer still shows — is now that the body is real + // prose rather than fallback text the blocks would replace. + expect(message.textIsStructuredFallback, isFalse); }); test('a blank follow-up question is not a chip', () { diff --git a/app/test/unit/server_message_content_blocks_test.dart b/app/test/unit/server_message_content_blocks_test.dart index 15434ed0524..cc7771e3228 100644 --- a/app/test/unit/server_message_content_blocks_test.dart +++ b/app/test/unit/server_message_content_blocks_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; import 'package:omi/backend/schema/message.dart'; void main() { @@ -71,12 +72,12 @@ void main() { ); expect(message.text, 'Meeting notes ready - Canonical title'); - expect(message.hideFromMobileChat, isFalse); + expect(message.textIsStructuredFallback, isFalse); }, ); test( - 'hides desktop goal and task chrome fallbacks from the mobile timeline', + 'keeps desktop goal and task chrome on the mobile timeline', () { final message = ServerMessage.fromJson( messageJson( @@ -84,45 +85,52 @@ void main() { contentBlocks: [ { 'type': 'goalLink', + 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again', }, - {'type': 'taskCard', 'taskId': 'task-1'}, - {'type': 'taskCard', 'taskId': 'task-2'}, - {'type': 'taskCard', 'taskId': 'task-3'}, + {'type': 'taskCard', 'id': 'block-task-1', 'taskId': 'task-1'}, + {'type': 'taskCard', 'id': 'block-task-2', 'taskId': 'task-2'}, + {'type': 'taskCard', 'id': 'block-task-3', 'taskId': 'task-3'}, ], ), ); expect(message.text, 'Goal - Make Omi Great Again\nTask\nTask\nTask'); - expect(message.hideFromMobileChat, isTrue); - expect(ServerMessage.visibleOnMobile([message]), isEmpty); + // The body is nothing but the synthesized fallback, so the interactive + // components replace it instead of repeating it. + expect(message.textIsStructuredFallback, isTrue); + expect(message.typedContentBlocks, hasLength(4)); + expect(message.typedContentBlocks.first, isA<GoalLinkContentBlock>()); + expect(message.typedContentBlocks.last, isA<TaskCardContentBlock>()); }, ); - test('hides stored one-line goal/task fallback dumps', () { + test('keeps stored one-line goal/task fallback dumps renderable', () { final message = ServerMessage.fromJson( messageJson( text: 'Goal - Make Omi Great Again Task Task Task', contentBlocks: [ - {'type': 'goalLink', 'summary': 'Make Omi Great Again'}, - {'type': 'taskCard', 'taskId': 'task-1'}, - {'type': 'taskCard', 'taskId': 'task-2'}, - {'type': 'taskCard', 'taskId': 'task-3'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, + {'type': 'taskCard', 'id': 'block-task-1', 'taskId': 'task-1'}, + {'type': 'taskCard', 'id': 'block-task-2', 'taskId': 'task-2'}, + {'type': 'taskCard', 'id': 'block-task-3', 'taskId': 'task-3'}, ], ), ); - expect(message.hideFromMobileChat, isTrue); + expect(message.textIsStructuredFallback, isTrue); + expect(message.typedContentBlocks, hasLength(4)); }); - test('hides question cards that have no other content', () { + test('keeps question cards that have no other content', () { final message = ServerMessage.fromJson( messageJson( text: 'What should we focus on?', contentBlocks: [ { 'type': 'questionCard', + 'id': 'block-question', 'questionId': 'question-1', 'text': 'What should we focus on?', 'subject': {'kind': 'goal', 'id': 'goal-1'}, @@ -134,7 +142,8 @@ void main() { ), ); - expect(message.hideFromMobileChat, isTrue); + expect(message.textIsStructuredFallback, isTrue); + expect(message.typedContentBlocks.single, isA<QuestionCardContentBlock>()); }); test('keeps meeting-note cards and mixed useful blocks', () { @@ -144,6 +153,7 @@ void main() { contentBlocks: [ { 'type': 'conversationLink', + 'id': 'block-conversation', 'conversationId': 'conversation-1', 'summary': 'Founders explore AI memory', }, @@ -154,8 +164,8 @@ void main() { messageJson( text: 'I started tracking this.', contentBlocks: [ - {'type': 'text', 'text': 'I started tracking this.'}, - {'type': 'goalLink', 'summary': 'Make Omi Great Again'}, + {'type': 'text', 'id': 'block-text', 'text': 'I started tracking this.'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, ], ), ); @@ -163,19 +173,14 @@ void main() { messageJson( text: 'Here is a real reply about the weather.', contentBlocks: [ - {'type': 'goalLink', 'summary': 'Make Omi Great Again'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, ], ), ); - expect(meeting.hideFromMobileChat, isFalse); - expect(mixed.hideFromMobileChat, isFalse); - expect(prose.hideFromMobileChat, isFalse); - expect(ServerMessage.visibleOnMobile([meeting, mixed, prose]), [ - meeting, - mixed, - prose, - ]); + expect(meeting.typedContentBlocks.single, isA<ConversationLinkContentBlock>()); + expect(mixed.typedContentBlocks, hasLength(2)); + expect(prose.textIsStructuredFallback, isFalse); }); test('decodes optional evidence envelope without changing the answer text', () { diff --git a/app/test/widgets/chat_content_blocks_test.dart b/app/test/widgets/chat_content_blocks_test.dart new file mode 100644 index 00000000000..3fe2f53101c --- /dev/null +++ b/app/test/widgets/chat_content_blocks_test.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/messages.dart'; +import 'package:omi/backend/schema/action_item.dart'; +import 'package:omi/backend/schema/gen/action_items_folders_wire.g.dart' as wire; +import 'package:omi/backend/schema/memory.dart'; +import 'package:omi/backend/schema/message.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/chat/widgets/ai_message.dart'; +import 'package:omi/providers/action_items_provider.dart'; +import 'package:omi/providers/connectivity_provider.dart'; +import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/providers/goals_provider.dart'; +import 'package:omi/providers/memories_provider.dart'; +import 'package:omi/providers/message_provider.dart'; + +/// Records the single task-mutation path instead of hitting the network. +class _RecordingActionItemsProvider extends ActionItemsProvider { + _RecordingActionItemsProvider(this._items) + : super( + getActionItems: ({ + int limit = 100, + int offset = 0, + bool? completed, + String? conversationId, + DateTime? startDate, + DateTime? endDate, + }) async => + const wire.GeneratedActionItemsResponse(actionItems: []), + ); + + final List<ActionItemWithMetadata> _items; + final List<(String, bool)> updates = []; + + @override + List<ActionItemWithMetadata> get actionItems => _items; + + @override + bool get isLoading => false; + + @override + Future<void> ensureLoaded({bool showShimmer = false}) async {} + + @override + Future<void> updateActionItemState(ActionItemWithMetadata item, bool newState) async { + updates.add((item.id, newState)); + notifyListeners(); + } +} + +/// Records what the question card asked the chat to send. +class _RecordingMessageProvider extends MessageProvider { + final List<String> sent = []; + + @override + Future sendMessageStreamToServer(String text, {ChatPageContext? context}) async { + sent.add(text); + } +} + +class _StubMemoriesProvider extends MemoriesProvider { + _StubMemoriesProvider(this._memories); + + final List<Memory> _memories; + + @override + List<Memory> get memories => _memories; + + @override + bool get loading => false; +} + +class _StubGoalsProvider extends GoalsProvider { + @override + bool get isLoading => false; +} + +void main() { + ActionItemWithMetadata task({required String id, bool completed = false}) { + return wire.GeneratedActionItemResponse( + id: id, + description: 'Send the launch email', + completed: completed, + ); + } + + ServerMessage messageWithBlocks({String? selectedOptionId}) { + return ServerMessage( + 'ai-1', + DateTime.parse('2026-09-01T12:00:00Z'), + 'Here is what I found.', + MessageSender.ai, + MessageType.text, + null, + false, + const [], + const [], + const [], + contentBlocks: [ + {'type': 'text', 'id': 'block-text', 'text': 'Here is what I found.'}, + {'type': 'taskCard', 'id': 'block-task', 'taskId': 'task-1'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Ship the release'}, + { + 'type': 'captureLink', + 'id': 'block-capture', + 'conversationId': 'conversation-1', + 'summary': 'Monday standup', + }, + { + 'type': 'conversationLink', + 'id': 'block-conversation', + 'conversationId': 'conversation-2', + 'summary': 'Weekly planning', + 'recommendedActionItems': [ + {'description': 'Draft the launch plan'}, + ], + }, + {'type': 'memoryLink', 'id': 'block-memory', 'memoryId': 'memory-1', 'summary': 'Prefers dark mode'}, + { + 'type': 'questionCard', + 'id': 'block-question', + 'questionId': 'question-1', + 'text': 'What should we do next?', + 'subject': {'kind': 'goal', 'id': 'goal-1'}, + 'options': [ + {'optionId': 'ship', 'label': 'Ship it', 'preparedAnswer': 'Ship it today'}, + {'optionId': 'later', 'label': 'Ask me later', 'preparedAnswer': 'Remind me tomorrow', 'defer': true}, + ], + if (selectedOptionId != null) 'selectedOptionId': selectedOptionId, + }, + ], + ); + } + + Future< + ( + _RecordingActionItemsProvider, + _RecordingMessageProvider, + )> pumpBlocks( + WidgetTester tester, { + required ServerMessage message, + List<ActionItemWithMetadata> tasks = const [], + }) async { + final actionItems = _RecordingActionItemsProvider(tasks); + final messages = _RecordingMessageProvider(); + final conversations = ConversationProvider(isSignedIn: () => false); + addTearDown(conversations.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider<ActionItemsProvider>.value(value: actionItems), + ChangeNotifierProvider<MessageProvider>.value(value: messages), + ChangeNotifierProvider<GoalsProvider>(create: (_) => _StubGoalsProvider()), + ChangeNotifierProvider<MemoriesProvider>(create: (_) => _StubMemoriesProvider(const [])), + ChangeNotifierProvider.value(value: conversations), + ChangeNotifierProvider(create: (_) => ConnectivityProvider()), + ], + child: MaterialApp( + theme: ThemeData.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: AIMessage( + message: message, + sendMessage: (text) => messages.sendMessageStreamToServer(text), + displayOptions: false, + updateConversation: (_) {}, + setMessageNps: (int value, {String? reason}) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return (actionItems, messages); + } + + testWidgets('every interactable block renders its keyed component', (tester) async { + await pumpBlocks( + tester, + message: messageWithBlocks(), + tasks: [task(id: 'task-1')], + ); + + expect(find.byKey(const Key('chat-block-taskCard-block-task')), findsOneWidget); + expect(find.byKey(const Key('chat-block-taskCard-block-task-toggle')), findsOneWidget); + // No goal is loaded, so the goal link shows its unavailable state rather + // than a button that cannot resolve. + expect(find.byKey(const Key('chat-block-goalLink-block-goal-unavailable')), findsOneWidget); + expect(find.byKey(const Key('chat-block-captureLink-block-capture')), findsOneWidget); + expect(find.byKey(const Key('chat-block-captureLink-block-capture-open')), findsOneWidget); + expect(find.byKey(const Key('chat-block-conversationLink-block-conversation')), findsOneWidget); + expect(find.byKey(const Key('chat-block-conversationLink-block-conversation-open')), findsOneWidget); + expect(find.byKey(const Key('chat-block-memoryLink-block-memory-unavailable')), findsOneWidget); + expect(find.byKey(const Key('chat-block-questionCard-block-question')), findsOneWidget); + + // Message text is preserved beside the components. + expect(find.textContaining('Here is what I found.'), findsWidgets); + // Conversation link's recommended items are listed. + expect(find.text('Draft the launch plan'), findsOneWidget); + // Task description comes from the resolved task, not the block. + expect(find.text('Send the launch email'), findsOneWidget); + }); + + testWidgets('an unresolved task shows the unavailable state', (tester) async { + await pumpBlocks(tester, message: messageWithBlocks()); + + expect(find.byKey(const Key('chat-block-taskCard-block-task-unavailable')), findsOneWidget); + expect(find.byKey(const Key('chat-block-taskCard-block-task-toggle')), findsNothing); + }); + + testWidgets('tapping the task checkbox toggles it through the tasks provider', (tester) async { + final (actionItems, _) = await pumpBlocks( + tester, + message: messageWithBlocks(), + tasks: [task(id: 'task-1')], + ); + + final toggle = find.byKey(const Key('chat-block-taskCard-block-task-toggle')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pump(); + + expect(actionItems.updates, [('task-1', true)]); + }); + + testWidgets('tapping a question option sends its prepared answer', (tester) async { + final (_, messages) = await pumpBlocks(tester, message: messageWithBlocks()); + + final option = find.byKey(const Key('chat-block-questionCard-block-question-option-ship')); + await tester.ensureVisible(option); + await tester.tap(option); + await tester.pump(); + + expect(messages.sent, ['Ship it today']); + }); + + testWidgets('a deferral option sends its prepared answer too', (tester) async { + final (_, messages) = await pumpBlocks(tester, message: messageWithBlocks()); + + final option = find.byKey(const Key('chat-block-questionCard-block-question-option-later')); + await tester.ensureVisible(option); + await tester.tap(option); + await tester.pump(); + + expect(messages.sent, ['Remind me tomorrow']); + }); + + testWidgets('an answered question keeps only the chosen option, disabled', (tester) async { + final (_, messages) = await pumpBlocks( + tester, + message: messageWithBlocks(selectedOptionId: 'ship'), + ); + + expect(find.byKey(const Key('chat-block-questionCard-block-question-option-later')), findsNothing); + final chosen = find.byKey(const Key('chat-block-questionCard-block-question-option-ship')); + expect(chosen, findsOneWidget); + expect(tester.widget<OutlinedButton>(chosen).onPressed, isNull); + expect(messages.sent, isEmpty); + }); + + testWidgets('a chrome-only message renders components instead of its fallback dump', (tester) async { + final message = ServerMessage.fromJson({ + 'id': 'ai-2', + 'created_at': '2026-09-01T12:00:00Z', + 'text': '', + 'sender': 'ai', + 'type': 'text', + 'content_blocks': [ + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Ship the release'}, + {'type': 'taskCard', 'id': 'block-task', 'taskId': 'task-1'}, + ], + }); + + await pumpBlocks(tester, message: message, tasks: [task(id: 'task-1')]); + + expect(find.byKey(const Key('chat-block-taskCard-block-task')), findsOneWidget); + expect(find.textContaining('Goal - Ship the release'), findsNothing); + }); +} diff --git a/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift b/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift index 2d8785118c5..30c077e7d48 100644 --- a/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift +++ b/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift @@ -47,9 +47,9 @@ extension DesktopAutomationActionRegistry { + "Errors on shells whose Home has no stage; connectors live on the Apps page there." ) { _ in // `homeMode` is the stage's own answer to "is there a Connect tray here". It is written only - // by the view that renders the stage and is nil everywhere else (`HomeStageAutomationPolicy`), - // so this cannot succeed silently on a Home that has no tray to toggle — which is exactly what - // it did, answering "ok" and doing nothing, from the query-shell Home landing until now. + // by the view that renders the stage. `DashboardPage` was that view and no longer exists, so + // this is now always nil and the action always refuses — rather than answering "ok" and doing + // nothing, which is what it did from the query-shell Home landing until this guard. guard DesktopAutomationStateStore.shared.current().homeMode != nil else { return [ "error": "no Home stage on this shell, so there is no Connect tray to toggle — " diff --git a/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift b/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift index 5e2dd39eeb7..88574a5f91d 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift @@ -249,6 +249,42 @@ enum ChatCitationMarkup { !kindOnlyMatches(in: text).isEmpty } + /// References a follow-up borrows from the turns before it. + /// + /// Ordinals are assigned per attempt, so `[1]` in one answer and `[1]` in the + /// next can name different sources. But a turn that retrieved nothing has no + /// ordinals of its own, and when the model writes `[1]` there it is pointing + /// back at the list the reader was just shown — "pick one conversation from + /// that day" answered without a tool call is exactly this. Left unbound the + /// marker drew as plain text next to a title the reader could not open. + /// + /// Only ordinals this turn cannot resolve itself are borrowed, and each from + /// the nearest earlier assistant turn that persisted it, so a turn's own + /// provenance always outranks the past and a stale list is never reached + /// past a fresher one that has the same number. + static func inheritedReferences( + citedIn message: ChatMessage, + resolved: [ChatCitationReference], + earlierTurns: [ChatMessage], + lookback: Int = 8 + ) -> [ChatCitationReference] { + var unresolved = message.citedCitationOrdinals.subtracting(resolved.map(\.ordinal)) + guard !unresolved.isEmpty else { return [] } + var inherited = [ChatCitationReference]() + var searched = 0 + for earlier in earlierTurns.reversed() where earlier.sender == .ai && earlier.id != message.id { + guard searched < lookback else { break } + searched += 1 + for block in earlier.contentBlocks { + guard case .citation(_, let reference) = block, unresolved.remove(reference.ordinal) != nil + else { continue } + inherited.append(reference) + } + if unresolved.isEmpty { break } + } + return inherited.sorted { $0.ordinal < $1.ordinal } + } + /// Replace `[memory]` / `[conversation]` with the numeric marker for the best matching source of /// that kind. Unmatched labels stay inert instead of opening a random row. static func resolvingKindLabels( @@ -443,13 +479,22 @@ enum ChatCitationMarkup { /// Rich blocks are an authoritative selection made by the model. If it omits inline markers /// after rendering those blocks, retain source discoverability as one compact inline fallback. + /// + /// `renderedEntityIDs` are the entities the turn already draws as their own + /// components. A rendered task card is a better citation of that task than + /// `[3]` is — it opens the same thing and says what it is — so a rail that + /// only repeats those ids is noise printed under the cards, and now that + /// components are a turn's whole answer rather than a garnish, it is noise on + /// every such turn. static func appendingSelectedSources( to text: String, selectedReferences: [ChatCitationReference], requestedSources: Bool = false, - retrievedReferences: [ChatCitationReference] = [] + retrievedReferences: [ChatCitationReference] = [], + renderedEntityIDs: Set<String> = [] ) -> String { - let fallback = selectedReferences.isEmpty && requestedSources ? retrievedReferences : selectedReferences + let selection = selectedReferences.isEmpty && requestedSources ? retrievedReferences : selectedReferences + let fallback = selection.filter { !renderedEntityIDs.contains($0.sourceID) } guard !fallback.isEmpty else { return text } let fallbackOrdinals = Set(fallback.map(\.ordinal)) let hasResolvedNumericCitation = ordinals(in: text).contains { fallbackOrdinals.contains($0) } @@ -460,6 +505,23 @@ enum ChatCitationMarkup { return text + "\n\nSources: \(markers)" } + /// The entities this turn already draws as components, by the id a citation + /// would carry for the same thing. + static func renderedEntityIDs(in blocks: [ChatContentBlock]) -> Set<String> { + var identifiers = Set<String>() + for block in blocks { + switch block { + case .taskCard(_, let taskId): identifiers.insert(taskId) + case .goalLink(_, let goalId, _): identifiers.insert(goalId) + case .captureLink(_, let conversationId, _, _): identifiers.insert(conversationId) + case .conversationLink(_, let conversationId, _, _): identifiers.insert(conversationId) + case .memoryLink(_, let memoryId, _): identifiers.insert(memoryId) + default: continue + } + } + return identifiers + } + private static func webReferences(in text: String) -> [ChatCitationReference] { guard let expression = try? NSRegularExpression( @@ -539,13 +601,19 @@ extension ChatMessage { } } - mutating func persistCitedReferences(from references: [ChatCitationReference]) { + /// Every numeric marker the answer writes, in its body and its text blocks. + var citedCitationOrdinals: Set<Int> { var cited = Set(ChatCitationMarkup.ordinals(in: text)) for block in contentBlocks { if case .text(_, let blockText) = block { cited.formUnion(ChatCitationMarkup.ordinals(in: blockText)) } } + return cited + } + + mutating func persistCitedReferences(from references: [ChatCitationReference]) { + let cited = citedCitationOrdinals let existing = Set( contentBlocks.compactMap { block -> Int? in guard case .citation(_, let reference) = block else { return nil } @@ -601,12 +669,14 @@ extension ChatMessage { retrievedReferences: [ChatCitationReference], fallbackText: String = "" ) { + let rendered = ChatCitationMarkup.renderedEntityIDs(in: contentBlocks) func apply(_ value: String) -> String { ChatCitationMarkup.appendingSelectedSources( to: value, selectedReferences: selectedReferences, requestedSources: requestedSources, - retrievedReferences: retrievedReferences) + retrievedReferences: retrievedReferences, + renderedEntityIDs: rendered) } if text.isEmpty { text = fallbackText diff --git a/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift b/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift index 32ba62696b9..7946a2091f2 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift @@ -1,7 +1,20 @@ import Foundation enum ProactiveNotificationKind: String, Equatable, CaseIterable { + /// **Decode-only.** Historical rows were journaled under a bare + /// `notification:<uuid>` key, which reads back as this. No producer may pass + /// it: `showNotification` requires an explicit kind, and a card with no + /// category of its own is `.functional`, not "Notification". case general + /// A system notice that is not a proactive observation — screen-recording + /// reset, a support reply, an onboarding test ping. It is ungated by the five + /// category toggles, exactly as `.general` was. + case functional + /// Trial/plan messaging. Never journaled: it is product copy about billing, + /// not something Omi observed. + case trial + /// First-run permission help. Never journaled, for the same reason. + case onboarding case suggestion case insight case task @@ -22,7 +35,9 @@ enum ProactiveNotificationKind: String, Equatable, CaseIterable { case "insight": return .insight case "task_candidate": return .task case "resurface": return .resurface - default: return .general + // An unrecognised director decision is a system notice, not an + // uncategorised observation: `.general` is decode-only. + default: return .functional } } @@ -35,7 +50,20 @@ enum ProactiveNotificationKind: String, Equatable, CaseIterable { case "goals": return .goal case "meeting-notes": return .meetingNotes case "integration_connect": return .integration - default: return .general + case "trial": return .trial + case "onboarding": return .onboarding + default: return .functional + } + } + + /// Kinds whose cards are presentation only and must never enter the chat + /// journal. See `FloatingControlBarManager.persistNotificationMessageIfNeeded`. + var isJournaled: Bool { + switch self { + case .trial, .onboarding: return false + case .general, .functional, .suggestion, .insight, .task, .memory, .goal, .meetingNotes, + .resurface, .integration: + return true } } } @@ -58,6 +86,8 @@ enum ChatContinuityInvariants { } static func proactiveNotificationContinuityKey(id: UUID, kind: ProactiveNotificationKind) -> String { + // `.general` is decode-only and unreachable from a producer, so this branch + // exists to keep the historical bare key round-tripping, never to mint one. guard kind != .general else { return proactiveNotificationContinuityKey(id: id) } return "\(proactiveNotificationContinuityKeyPrefix)\(kind.rawValue):\(id.uuidString)" } diff --git a/desktop/macos/Desktop/Sources/Chat/ChatStreamingBuffer.swift b/desktop/macos/Desktop/Sources/Chat/ChatStreamingBuffer.swift index 4e51f934ace..0fecdd8217b 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatStreamingBuffer.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatStreamingBuffer.swift @@ -1,5 +1,32 @@ import Foundation +/// How much of the buffered text one flush lets through. +/// +/// The wire delivers an answer in bursts — a provider chunk, a whole paragraph +/// the moment a tool returns — and a flush that dumped everything it had made +/// the transcript lurch by a sentence at a time and then sit still. Revealing +/// a bounded slice per flush turns those bursts into a steady flow: a small +/// backlog drains over a handful of flushes, a large one is let through fast +/// enough that the reader is never far behind the model, and the tail of every +/// burst tapers rather than stops. +enum ChatStreamingReveal { + /// Characters the reveal may trail the wire by before it stops pacing and + /// simply catches up. About two lines of prose at the transcript's width. + static let maximumLag = 480 + /// Fewest characters a flush reveals while anything is pending, so a trickle + /// still moves and a taper still ends. + static let minimumPerFlush = 4 + /// A backlog drains over roughly this many flushes. + static let drainFlushes = 5 + + static func characters(pending: Int) -> Int { + guard pending > 0 else { return 0 } + let paced = max(minimumPerFlush, Int((Double(pending) / Double(drainFlushes)).rounded(.up))) + let catchUp = pending - maximumLag + return min(pending, max(paced, catchUp)) + } +} + final class ChatStreamingBuffer { private enum PendingSegment { case text(messageId: String, text: String) @@ -46,6 +73,66 @@ final class ChatStreamingBuffer { } } + /// Characters of answer text waiting to be shown. + var pendingTextCount: Int { + pendingSegments.reduce(0) { total, segment in + if case .text(_, let text) = segment { return total + text.count } + return total + } + } + + /// Re-arm the flush timer. `flushPaced` leaves a remainder behind on purpose, + /// and the remainder needs a next flush that no new delta may ever schedule. + func scheduleFlush(_ scheduleFlush: @escaping () -> Void) { + scheduleFlushIfNeeded(scheduleFlush) + } + + /// Apply the pending deltas in order, but let only `ChatStreamingReveal`'s + /// share of the answer text through; the rest stays queued, at the head, + /// for the next flush. Thinking is not paced — it is folded away behind a + /// disclosure, so there is no flow to smooth. Returns whether anything is + /// still waiting. + @discardableResult + func flushPaced( + messages: inout [ChatMessage], + normalizeText: (_ message: ChatMessage, _ text: String) -> String = { _, text in text } + ) -> Bool { + flushWorkItem?.cancel() + flushWorkItem = nil + + var budget = ChatStreamingReveal.characters(pending: pendingTextCount) + var consumed = 0 + segments: while consumed < pendingSegments.count { + let segment = pendingSegments[consumed] + guard let index = messages.firstIndex(where: { $0.id == segment.messageId }) else { + consumed += 1 + continue + } + switch segment { + case .thinking(_, let text): + appendThinkingSegment(text, to: &messages[index]) + consumed += 1 + case .text(let messageId, let text): + guard budget > 0 else { break segments } + if text.count <= budget { + appendTextSegment(text, to: &messages[index], normalizeText: normalizeText) + budget -= text.count + consumed += 1 + } else { + appendTextSegment(String(text.prefix(budget)), to: &messages[index], normalizeText: normalizeText) + pendingSegments[consumed] = .text(messageId: messageId, text: String(text.dropFirst(budget))) + budget = 0 + break segments + } + } + } + pendingSegments.removeFirst(consumed) + return !pendingSegments.isEmpty + } + + /// Apply everything pending at once. This is the flush for a boundary — a + /// tool starting, the turn settling — where the order of what follows + /// depends on all of the text being in place first. func flush( messages: inout [ChatMessage], normalizeText: (_ message: ChatMessage, _ text: String) -> String = { _, text in text } diff --git a/desktop/macos/Desktop/Sources/Chat/RuntimeOwnerIdentity.swift b/desktop/macos/Desktop/Sources/Chat/RuntimeOwnerIdentity.swift index a3ee5cfba27..d4175c321b9 100644 --- a/desktop/macos/Desktop/Sources/Chat/RuntimeOwnerIdentity.swift +++ b/desktop/macos/Desktop/Sources/Chat/RuntimeOwnerIdentity.swift @@ -185,6 +185,18 @@ private final class EffectiveOwnerAuthorizationRevocation: @unchecked Sendable { extension Notification.Name { /// Effective owner changed (sign-in, sign-out, account switch, or an /// automation override). Carries no owner id or other user content. + /// + /// **Post it on the main thread.** `performEffectiveOwnerTransition` does + /// (`await MainActor.run`), and observers depend on both halves of that: + /// `NotificationCenter` delivers synchronously on the posting thread, which is + /// what lets a surface fence itself *during* the transition rather than a + /// runloop later — see `IntegrationNudgeCoordinator`. Most observers are + /// `@MainActor` types whose sink closure carries an isolation check on entry, + /// so a post from a background thread fails `dispatch_assert_queue` and traps, + /// taking the whole process rather than one observer. Nothing inside the + /// closure can guard against that: the check runs before its first statement, + /// and hopping upstream would trade the crash for losing the synchronous + /// fence. The poster is the only place that can be both correct and prompt. static let runtimeOwnerDidChange = Notification.Name("com.omi.desktop.runtimeOwnerDidChange") } diff --git a/desktop/macos/Desktop/Sources/ConcurrencySendable.swift b/desktop/macos/Desktop/Sources/ConcurrencySendable.swift index a46c6a1d117..429e95e8c84 100644 --- a/desktop/macos/Desktop/Sources/ConcurrencySendable.swift +++ b/desktop/macos/Desktop/Sources/ConcurrencySendable.swift @@ -39,8 +39,6 @@ extension OmiAPI.WorkstreamDetailProjection: @unchecked Sendable {} extension AssistantSettingsResponse: @unchecked Sendable {} extension OmiAPI.RecommendationSubjectKind: @unchecked Sendable {} extension OmiAPI.GoalStatus: @unchecked Sendable {} -extension DashboardRecommendation: @unchecked Sendable {} -extension DashboardRecommendationDestination: @unchecked Sendable {} extension OmiAPI.FeedbackSubjectKind: @unchecked Sendable {} extension OmiAPI.ArtifactDescriptorCreate: @unchecked Sendable {} extension OmiAPI.ContinuationCheckpointUpsert: @unchecked Sendable {} diff --git a/desktop/macos/Desktop/Sources/DefaultsKey.swift b/desktop/macos/Desktop/Sources/DefaultsKey.swift index ba3efa08c97..14c6dea418c 100644 --- a/desktop/macos/Desktop/Sources/DefaultsKey.swift +++ b/desktop/macos/Desktop/Sources/DefaultsKey.swift @@ -191,6 +191,13 @@ struct ScopedDefaultsKey { Self(rawValue: "dailySummary.lastSeenID.v1.\(ownerID)") } + /// Owner-scoped id of the daily summary that was on screen when the owner last cleared Chat. + /// The card is chrome rather than a turn (INV-CHAT-1), so clearing the transcript cannot + /// delete it — this is what makes Clear take it away anyway, until a newer summary arrives. + static func dailySummaryClearedID(ownerID: String) -> Self { + Self(rawValue: "dailySummary.clearedID.v1.\(ownerID)") + } + static func importConnectorAvailabilityText(connectorID: String) -> Self { Self(rawValue: "appsImportConnectorAvailabilityText.\(connectorID)") } @@ -217,12 +224,6 @@ struct ScopedDefaultsKey { Self(rawValue: "proactiveTaskInterruptionLedger.v1.\(ownerID)") } - /// Owner-scoped record of which Home knows-list rows have already been shown, - /// opened, or dismissed. Without it a thin candidate source repeats the same - /// four rows on every visit; owner-scoped for the same bleed class as above. - static func homeKnowsImpressions(ownerID: String) -> Self { - Self(rawValue: "homeKnows.impressions.v1.\(ownerID)") - } } /// Typed accessors that take a `DefaultsKey` instead of a `String`. diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift index 8ee97c5835f..d78327ecf83 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift @@ -83,15 +83,12 @@ extension DesktopAutomationBridge { ) } - /// Confirms the target resolves to a known chat-first or legacy destination - /// so the acknowledgement path cannot mask an unknown route as success. + /// Confirms the target resolves to a known destination so the acknowledgement + /// path cannot mask an unknown route as success. private func validateKnownNavigationTarget( _ payload: DesktopAutomationNavigationRequest ) throws { - let isKnown = - ChatFirstRoute.automationVisibilityDestination(named: payload.target) != nil - || legacyAutomationDestinationTitle(named: payload.target) != nil - guard isKnown else { + guard ChatFirstRoute.automationVisibilityDestination(named: payload.target) != nil else { throw DesktopAutomationActionError.invalidParams("unknown_navigation_target") } } @@ -100,7 +97,6 @@ extension DesktopAutomationBridge { _ payload: DesktopAutomationNavigationRequest ) async throws -> DesktopAutomationSnapshot { let expectedChatFirstRoute = ChatFirstRoute.automationVisibilityDestination(named: payload.target)?.stableName - let expectedLegacyTitle = legacyAutomationDestinationTitle(named: payload.target) let deadline = Date().addingTimeInterval(5) while Date() < deadline { @@ -108,10 +104,8 @@ extension DesktopAutomationBridge { if !snapshot.snapshotStale, DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( shellVariant: snapshot.shellVariant, - selectedTab: snapshot.selectedTab, visibleChatFirstRoute: snapshot.visibleChatFirstRoute, - expectedChatFirstRoute: expectedChatFirstRoute, - expectedLegacyTitle: expectedLegacyTitle + expectedChatFirstRoute: expectedChatFirstRoute ) { return snapshot @@ -121,40 +115,17 @@ extension DesktopAutomationBridge { throw DesktopAutomationActionError.invalidParams("navigation_target_not_visible") } - private func legacyAutomationDestinationTitle(named target: String) -> String? { - switch target.lowercased().replacingOccurrences(of: "-", with: "_") { - // Home is the chat surface, so "chat" and "home" name the same destination. - case "dashboard", "home", "chat": return "Home" - case "conversations": return "Conversations" - case "memories": return "Memories" - case "tasks": return "Tasks" - case "rewind": return "Rewind" - case "apps", "integrations": return "Apps" - case "settings": return "Settings" - case "permissions": return "Permissions" - case "help": return "Help from Founder" - default: return nil - } - } } -/// Shared legacy and cohort visibility comparison retained separately from the -/// HTTP bridge so it has no access to rollout state beyond the sampled snapshot. +/// Visibility comparison retained separately from the HTTP bridge so it has no +/// access to shell state beyond the sampled snapshot. enum DesktopAutomationNavigationVisibilityPolicy { static func isTargetVisible( shellVariant: String?, - selectedTab: String?, visibleChatFirstRoute: String?, - expectedChatFirstRoute: String?, - expectedLegacyTitle: String? + expectedChatFirstRoute: String? ) -> Bool { - switch shellVariant { - case "chat_first": - return expectedChatFirstRoute != nil && visibleChatFirstRoute == expectedChatFirstRoute - case "legacy": - return expectedLegacyTitle != nil && selectedTab == expectedLegacyTitle - default: - return false - } + guard shellVariant == DesktopAutomationSnapshot.singleShellVariant else { return false } + return expectedChatFirstRoute != nil && visibleChatFirstRoute == expectedChatFirstRoute } } diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index 39fb0cc53b4..4ae68d08431 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -138,6 +138,10 @@ enum DesktopAutomationLaunchOptions { } struct DesktopAutomationSnapshot: Codable, Sendable { + /// The app has one shell. Flows and the navigation-visibility policy still read + /// `shellVariant`, so it is pinned here rather than removed from the contract. + static let singleShellVariant = "chat_first" + var bridgeEnabled: Bool var bridgePort: UInt16 var bundleIdentifier: String @@ -146,14 +150,14 @@ struct DesktopAutomationSnapshot: Codable, Sendable { var selectedTabIndex: Int? var selectedSettingsSection: String? var highlightedSettingId: String? - var usesLegacyHomeDesign: Bool - /// Home stage mode: `hub`, `chat`, or `connect`. Written only by `DashboardPage`, which is the only - /// view that renders the stage; nil whenever nothing on screen has one — which includes the whole - /// legacy shell, whose Home is the query surface. Never defaulted: see `HomeStageAutomationPolicy`. + /// Home stage mode: `hub`, `chat`, or `connect`. `DashboardPage` was the only view that ever + /// rendered that stage and it no longer exists, so this is now always nil. Kept in the snapshot + /// so an older flow reading it sees "no stage" rather than a missing key. var homeMode: String? - /// `loading`, `legacy`, or `chat_first`; never a local rollout preference. + /// Always `chat_first` on a mounted shell: the app has exactly one. Nil only before the shell has + /// reported state. Never a local preference. var shellVariant: String? - /// Stable typed route for the Chat-first shell. Nil for the legacy shell. + /// Stable typed route for the one shell. var chatFirstRoute: String? /// Set only by the mounted Chat-first destination after it has appeared. This /// keeps a successful navigation response equivalent to the target being @@ -167,6 +171,7 @@ struct DesktopAutomationSnapshot: Codable, Sendable { /// never an analytics dimension or a persisted navigation value. var focusedEntityID: String? var isFocusedEntityAcknowledged: Bool + /// Retained for snapshot compatibility; the legacy sidebar shell is gone, so it is always false. var showsPrimarySidebar: Bool var isSidebarCollapsed: Bool var hasCompletedOnboarding: Bool @@ -464,7 +469,6 @@ final class DesktopAutomationStateStore { selectedTabIndex: nil, selectedSettingsSection: nil, highlightedSettingId: nil, - usesLegacyHomeDesign: false, homeMode: nil, shellVariant: nil, chatFirstRoute: nil, diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index 429541304fc..2432c46ae3d 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -207,7 +207,6 @@ struct AIResponseView: View { switch group { case .text(_, let text): OmiMarkdown(text: text, sender: .ai, citations: message.inlineCitationReferences) - .textSelection(.enabled) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) case .commentary(_, let text): @@ -228,12 +227,22 @@ struct AIResponseView: View { case .discoveryCard(_, let title, let summary, let fullText): DiscoveryCard(title: title, summary: summary, fullText: fullText) .frame(maxWidth: .infinity, alignment: .leading) - // The floating/notch surface never opts into rich chat-first controls. - // Keep journaled blocks inert if an older runtime projects them here. + // The notch projects the same journal as the main window, so it renders + // the same interactable cards. Taps route the one shell and summon the + // main window (`ChatFirstRichBlockContext.auxiliary`). + case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink: + if let context = ChatFirstRichBlockContext.floatingSurface { + ChatFirstRichBlockGroupView( + group: group, + messageID: message.id, + context: context + ) + .environment(\.colorScheme, .light) + .frame(maxWidth: .infinity, alignment: .leading) + } // The review card is three controls and an inline editor over stored memories — the // clearest case of a rich control this passive surface does not own. - case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink, - .memoryReviewCard: + case .memoryReviewCard: EmptyView() case .followUp(_, let question): if let onAskFollowUp { @@ -274,7 +283,6 @@ struct AIResponseView: View { } } else if !message.text.isEmpty { OmiMarkdown(text: message.text, sender: .ai, citations: message.inlineCitationReferences) - .textSelection(.enabled) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift index fc28f9352ed..2920185147f 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift @@ -97,7 +97,7 @@ enum ProactiveNotificationCopy { return ["memory", "memory saved"] case .integration: return ["integration"] - case .general: + case .general, .functional, .trial, .onboarding: return ["notification"] } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index ed1f3fa5940..1b070b3eb95 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -273,7 +273,7 @@ struct FloatingBarNotification: Identifiable, Equatable { title: String, message: String, assistantId: String, - kind: ProactiveNotificationKind? = nil, + kind: ProactiveNotificationKind, context: FloatingBarNotificationContext? = nil, action: FloatingBarNotificationAction? = nil, jitFeedbackContext: JITTriggerFeedbackContext? = nil, @@ -286,7 +286,10 @@ struct FloatingBarNotification: Identifiable, Equatable { self.title = title self.message = message self.assistantId = assistantId - self.kind = kind ?? ProactiveNotificationKind.from(assistantId: assistantId) + // Required, never derived here. Deriving it from `assistantId` meant every + // producer that forgot to say what its card was silently became `.general` + // and journaled a bare `notification:<uuid>` row badged "Notification". + self.kind = kind self.context = context self.action = action self.jitFeedbackContext = jitFeedbackContext diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 1e83b73c8f4..9ba44dd3703 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -2194,11 +2194,23 @@ private struct AgentMainChatView: View { case .discoveryCard(_, let title, let summary, let fullText): DiscoveryCard(title: title, summary: summary, fullText: fullText) .frame(maxWidth: .infinity, alignment: .leading) - // Rich controls are main-chat-only; floating/notch stays passive. The - // follow-up chip belongs to the answer surface, not this agent-pill - // transcript, which has no lane to send the next turn on. - case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink, - .followUp, .memoryReviewCard: + // The notch projects the same journal as the main window, so it + // renders the same interactable cards. Taps route the one shell and + // summon the main window (`ChatFirstRichBlockContext.auxiliary`). + case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink: + if let context = ChatFirstRichBlockContext.floatingSurface { + ChatFirstRichBlockGroupView( + group: group, + messageID: message.id, + context: context + ) + .environment(\.colorScheme, .light) + .frame(maxWidth: .infinity, alignment: .leading) + } + // The follow-up chip belongs to the answer surface, not this agent-pill + // transcript, which has no lane to send the next turn on, and the review + // card is a rich editor this passive surface does not own. + case .followUp, .memoryReviewCard: EmptyView() case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider @@ -2231,7 +2243,6 @@ private struct AgentMainChatView: View { let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { OmiMarkdown(text: trimmed, sender: .ai, citations: message.inlineCitationReferences) - .textSelection(.enabled) .environment(\.fontScale, 0.88) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index 5c7a65fc41e..19ba5b034c2 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -3299,7 +3299,8 @@ class FloatingControlBarManager { ownerID: RuntimeOwnerIdentity.currentOwnerId() ?? "", title: "Couldn't reach Omi", message: message, - assistantId: "reach_error" + assistantId: "reach_error", + kind: .functional ) ) } @@ -3522,7 +3523,9 @@ class FloatingControlBarManager { message: String, assistantId: String, sound: NotificationSound, - kind: ProactiveNotificationKind? = nil, + /// Required: what this card *is*. There is no assistant-id fallback — see + /// `FloatingBarNotification.init`. + kind: ProactiveNotificationKind, context: FloatingBarNotificationContext? = nil, action: FloatingBarNotificationAction? = nil, jitFeedbackContext: JITTriggerFeedbackContext? = nil, @@ -4639,6 +4642,10 @@ class FloatingControlBarManager { // read your inbox…" into the user's conversation history as though it // were an observation is noise they cannot act on there. notification.assistantId != IntegrationNudgeCoordinator.assistantID, + // Trial and onboarding cards are product copy — billing state and + // permission help — not something Omi observed. Writing them into the + // transcript is the same noise the integration offer above is excluded for. + notification.kind.isJournaled, // The meeting summary share card must not journal either: the durable // Chat surface for a finished meeting is the conversation-link card the // backend already materializes, and journaling here would produce a diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift index aa8c87b9615..bb442b7902c 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift @@ -19,7 +19,7 @@ enum InterjectDisplayDuration { return 4 case .insight, .suggestion: return 5 - case .general, .memory, .goal, .meetingNotes, .integration: + case .general, .functional, .trial, .onboarding, .memory, .goal, .meetingNotes, .integration: return 5 } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift index 3aaea2beea1..084f75054cb 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift @@ -167,6 +167,7 @@ final class NotchMomentsCoordinator { title: title, message: message, assistantId: assistantId, - sound: .none) + sound: .none, + kind: ProactiveNotificationKind.from(assistantId: assistantId)) } } diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift index ad651df5a21..5003fae7c58 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift @@ -479,7 +479,7 @@ enum GeneratedRealtimeTools { { "type": "function", "name": "get_action_items", - "description": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish'), a DATE RANGE ('what's due next week'), or the FULL list ('all my tasks') — for plain 'what's due today / overdue', prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", + "description": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish') or a DATE RANGE ('what's due next week') — for any plain question about the open list, prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", "parameters": { "type": "object", "properties": { @@ -613,7 +613,7 @@ enum GeneratedRealtimeTools { { "type": "function", "name": "get_tasks", - "description": "Read the user's tasks (overdue + due today) locally and get them back as text to speak. Fast synchronous read — use this for 'what are my tasks', 'what's due today', 'what's on my list'. Reading tasks is always a direct call, never background work.", + "description": "Read the user's open tasks locally and get them back as text to speak: everything overdue, everything due today, and everything on the list with no due date. This is the same list the Tasks page shows, so an empty result means the user genuinely has no open tasks — never say they have none without calling this first. Fast synchronous read — use it for 'what are my tasks', 'what's due today', 'what's on my list', 'what should I work on'. Reading tasks is always a direct call, never background work.", "parameters": { "type": "object", "properties": {}, diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift index 00f0429b56c..33af21eba60 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift @@ -417,7 +417,10 @@ enum GeneratedToolCapabilities { summary: "Retrieve conversations by recency or date range.", bullets: [ "Use for latest/recent conversations and time-based conversation retrieval.", - "For voice, this returns summaries only and should be spoken briefly." + "For voice, this returns summaries only and should be spoken briefly.", + "If the user asked to see, find, open, pick or choose a conversation — 'show me the call with Paul', 'which one was most interesting', 'find the meeting about pricing' — the conversation is the answer: render it as a captureLink block ({type:'captureLink', conversationId:'<canonical id from this result>', summary:'...'}) with render_chat_blocks, and keep the prose to one lead-in line. Do not answer with a bold title and a citation number in place of the component.", + "A follow-up that narrows an earlier result — 'pick one', 'the second one', 'tell me more about that one' — still renders the component for what it picks.", + "A recap of a day, a summary, a comparison, a count, or a list longer than three is prose that cites the conversations inline instead." ] ), Capability( @@ -428,7 +431,9 @@ enum GeneratedToolCapabilities { summary: "Search the user's past conversations by topic or exact canonical ID/share link.", bullets: [ "Use for specific topics, decisions, or events discussed in conversations.", - "For a canonical conversation UUID or https://h.omi.me/conversations/<uuid> link, pass it unchanged for an exact lookup." + "For a canonical conversation UUID or https://h.omi.me/conversations/<uuid> link, pass it unchanged for an exact lookup.", + "If the user asked to find, see, open or pick a conversation, the match is the answer: render it as a captureLink block ({type:'captureLink', conversationId:'<canonical id from this result>', summary:'...'}) with render_chat_blocks and keep the prose to one lead-in line. Up to three matches render; say how many more there are.", + "When the conversation is only evidence for something you are answering in prose — what was decided, whether it happened, what someone said — cite it inline and render nothing." ] ), Capability( @@ -438,7 +443,9 @@ enum GeneratedToolCapabilities { surfaces: Set([.desktopChat, .realtimeHub]), summary: "Retrieve stored facts, preferences, habits, people, and background about the user.", bullets: [ - "Use for broad 'what do you know about me' questions or personal facts." + "Use for broad 'what do you know about me' questions or personal facts.", + "If the user asked to see, review, find or pick specific memories, the memories are the answer: render the ones that matter as memoryLink blocks ({type:'memoryLink', memoryId:'<id from this result>', summary:'...'}) with render_chat_blocks — a count in prose, never a bulleted copy of the cards.", + "'What do you know about me' and other summaries, comparisons or long lists answer in prose and cite the memories inline instead." ] ), Capability( @@ -448,7 +455,9 @@ enum GeneratedToolCapabilities { surfaces: Set([.desktopChat, .realtimeHub]), summary: "Semantic search across user memories.", bullets: [ - "Use for a specific personal fact that is not already in the visible user context." + "Use for a specific personal fact that is not already in the visible user context.", + "If the user asked to find, see or pick a memory, the match is the answer: render up to three as memoryLink blocks ({type:'memoryLink', memoryId:'<id from this result>', summary:'...'}) with render_chat_blocks and keep the prose to one lead-in line.", + "When a memory is only evidence for an answer in prose, cite it inline and render nothing." ] ), Capability( @@ -565,8 +574,10 @@ enum GeneratedToolCapabilities { surfaces: Set([.desktopChat, .realtimeHub]), summary: "Retrieve the user's tasks with optional completion and due-date filters.", bullets: [ - "Use for completed tasks, date ranges, or the full task list.", - "For voice, prefer get_tasks for plain overdue/due-today questions." + "Use for completed tasks or an explicit date range.", + "For voice, prefer get_tasks for any plain question about the open list.", + "If the user asked to see, review, pick from or work through their tasks, the tasks are the answer: render the few that matter as taskCard blocks with render_chat_blocks. Say how many there are in total — a count, never their names. Naming them in the message, as a list or as bullets, prints every card twice: once as words that cannot be ticked off and once as the card itself.", + "If a task is only evidence for something you are answering in prose — how many are open, whether one exists, what a day contained — cite it inline and render nothing." ] ), Capability( @@ -690,10 +701,10 @@ enum GeneratedToolCapabilities { title: "Get Tasks", latency: .fastLocal, surfaces: Set([.realtimeHub]), - summary: "Read the user's overdue and due-today tasks locally.", + summary: "Read the user's open tasks locally: overdue, due today, and undated.", bullets: [ "Use for plain voice questions like what are my tasks, what's due today, or what's on my list.", - "Prefer get_action_items for completed tasks, date ranges, or the full list." + "Prefer get_action_items for completed tasks or an explicit date range." ] ), Capability( diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index cb5e05a48cd..4933bb07126 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -54,8 +54,8 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 - static let manifestDigest = "sha256:05dbd2cd609bbec77a825b010d6bc75e9dec8131d1ccaee3ea6c851e728fdccc" - static let chatFirstManifestDigest = "sha256:910b2affcb4d24cfe8f2112eb92d015bdb646032e610924ed90b9cfbb431fd59" + static let manifestDigest = "sha256:e8991c25a4912784c474dea8339dc21dc2e4c3a2fa9149c17b1a601565fd90d8" + static let chatFirstManifestDigest = "sha256:2feb70285d459c0279258c829377c6cfd454ef9ad5ebc6b5e3ef1fbdb4524bfd" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift b/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift index a017212433a..c69d934a10f 100644 --- a/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift +++ b/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift @@ -63,6 +63,7 @@ final class IntegrationNudgeCoordinator { message: match.entry.pitch, assistantId: IntegrationNudgeCoordinator.assistantID, sound: .none, + kind: .integration, action: .connectIntegration( telemetryID: match.entry.telemetryID, triggerID: match.trigger.id diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index 07434ca40c8..96f2a5f1203 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -7,6 +7,35 @@ import SwiftUI /// Choices are controls only while the kernel-backed parent is the completed /// tail of Main Chat. The runtime remains authoritative at selection time; /// this view's gate simply avoids presenting obsolete choices as actionable. +/// Whether a question card's options are pressable, dimmed, or gone. +/// +/// Three different situations used to collapse into one boolean, and the losing +/// two both rendered as "no options at all": a question already answered (right), +/// a question whose turn is no longer the tail (right), and a question on an +/// account whose capability has not resolved (wrong — that reader saw a question +/// with no visible answers and no explanation). +enum ChatFirstQuestionCardOptionsPolicy: Equatable { + case hidden + case enabled + case disabled + + static func presentation( + isActionable: Bool, + isCapabilityAvailable: Bool, + hasSelection: Bool, + hasOptions: Bool + ) -> Self { + guard hasOptions, !hasSelection else { return .hidden } + if isActionable { return .enabled } + // Capability-off is the only reason to show unpressable options: the + // question is live, we simply cannot answer it yet. + return isCapabilityAvailable ? .hidden : .disabled + } + + var isVisible: Bool { self != .hidden } + var isPressable: Bool { self == .enabled } +} + struct QuestionCardView: View { private struct Option: Identifiable { let id: String @@ -30,6 +59,10 @@ struct QuestionCardView: View { let options: [[String: Any]] let selectedOptionID: String? let isActionable: Bool + /// False while the server-owned capability has not resolved, or for an account + /// it does not cover. The options still render — a question with its answers + /// hidden reads as a question nobody asked — but they cannot be pressed. + let isCapabilityAvailable: Bool let onSelect: (String, Bool) -> Void private var validOptions: [Option] { options.compactMap(Option.init) } @@ -48,7 +81,17 @@ struct QuestionCardView: View { // A completed question remains useful transcript context, but its // suggestions disappear as soon as an answer exists or another bubble // has taken the tail. We never leave stale chips that look tappable. - if isActionable, selectedOptionID == nil, !validOptions.isEmpty { + // + // Capability-off is the one case that shows the chips *without* making + // them pressable: the question is real and its answers are the only thing + // that explains it, so they are dimmed rather than deleted. + let optionsPresentation = ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: isActionable, + isCapabilityAvailable: isCapabilityAvailable, + hasSelection: selectedOptionID != nil, + hasOptions: !validOptions.isEmpty + ) + if optionsPresentation.isVisible { FlowLayout(spacing: OmiSpacing.sm) { ForEach(validOptions) { option in Button { @@ -62,10 +105,19 @@ struct QuestionCardView: View { .glassChip() } .buttonStyle(.plain) + .disabled(!optionsPresentation.isPressable) + .opacity(optionsPresentation.isPressable ? 1 : 0.45) .accessibilityLabel("Send suggestion: \(option.label)") .accessibilityIdentifier("chat-first-question-\(questionID)-option-\(option.id)") } } + + if !optionsPresentation.isPressable { + Text("Answering is unavailable right now") + .scaledFont(size: OmiType.caption) + .foregroundStyle(Ink.secondary) + .accessibilityIdentifier("chat-first-question-\(questionID)-unavailable") + } } } .padding(.horizontal, OmiSpacing.md) @@ -103,6 +155,9 @@ struct TaskCardView: View { @State private var showCompletionAcknowledgement = false @State private var hydrationFinished = false @State private var retainedCompletedTask: TaskActionItem? + /// The completion the reader performed on this card, kept whatever the store + /// says afterwards. See `ChatFirstTaskCardPresentation.displayTask`. + @State private var locallyCompletedTask: TaskActionItem? init(taskID: String, tasksStore: TasksStore, navigation: ChatFirstShellNavigation) { self.taskID = taskID @@ -118,10 +173,21 @@ struct TaskCardView: View { (tasksStore.tasks + tasksStore.deletedTasks).contains { $0.id == taskID && $0.isRetired } } + /// The store's row for this card, retired or not. + /// + /// `liveTask` is a presentation filter, so it answers nil for a retired row — + /// which made it the wrong thing to reconcile a toggle against. Completing a + /// task whose local row carried a stale tombstone read back as "the mutation + /// did not land", and the card retired itself over the reader's own tick. + private var storeRecord: TaskActionItem? { + tasksStore.tasks.first { $0.id == taskID } + } + private var task: TaskActionItem? { ChatFirstTaskCardPresentation.displayTask( liveTask: liveTask, - retainedCompletedTask: retainedCompletedTask?.id == taskID ? retainedCompletedTask : nil + retainedCompletedTask: retainedCompletedTask?.id == taskID ? retainedCompletedTask : nil, + locallyCompletedTask: locallyCompletedTask?.id == taskID ? locallyCompletedTask : nil ) } @@ -142,6 +208,14 @@ struct TaskCardView: View { } else if hydrationFinished { ChatFirstUnavailableBlockView(entityName: "Task") .onAppear { + log( + "TaskCardView: \(taskID) unavailable — store=\(tasksStore.tasks.count)" + + " incomplete=\(tasksStore.incompleteTasks.count)" + + " completed=\(tasksStore.completedTasks.count)" + + " deleted=\(tasksStore.deletedTasks.count)" + + " present=\(tasksStore.tasks.contains { $0.id == taskID })" + + " retiredHere=\(isExplicitlyRetired)" + + " retained=\(retainedCompletedTask?.id ?? "none")") AnalyticsManager.shared.chatFirst( .richBlock(kind: .taskCard, outcome: .stalePlaceholder, action: .none) ) @@ -168,11 +242,26 @@ struct TaskCardView: View { hydrationFinished = false } let resolvedTask = await tasksStore.resolveCanonicalTask(id: taskID) - retainCompletedTaskIfNeeded(resolvedTask) - if resolvedTask == nil { - retainedCompletedTask = nil + switch ChatFirstTaskCardHydration.resolution( + isCancelled: Task.isCancelled, hasLiveTask: liveTask != nil) + { + case .abandon: + return + case .settle: + // The toggle won the race and put the task back in the store. That is + // a better answer than this hydration's, so take it. + retainCompletedTaskIfNeeded(liveTask) + hydrationFinished = true + case .adopt: + if resolvedTask == nil { + log("TaskCardView: \(taskID) hydrated to nothing — the store cannot vouch for this task") + } + // A store that cannot vouch for the row is not the same as a row the + // user retired, and only the second is grounds for taking a card away. + // `.onChange(of: isExplicitlyRetired)` is the one clearer. + retainCompletedTaskIfNeeded(resolvedTask) + hydrationFinished = true } - hydrationFinished = true } } @@ -271,7 +360,16 @@ struct TaskCardView: View { await tasksStore.toggleTask(task) isToggling = false - let reconciledTask = self.task + let reconciledTask = self.storeRecord + // The reader ticked this card and the store took the mutation. That is + // the answer the card shows from here on: a retirement discovered + // afterwards — a stale local tombstone, a lane that cannot vouch for the + // row — is not grounds for erasing a completion they performed. + if intendedCompletion { + locallyCompletedTask = reconciledTask?.completed == true ? reconciledTask : nil + } else { + locallyCompletedTask = nil + } AnalyticsManager.shared.chatFirst( .taskMutation( lifecycle: reconciledTask?.completed == intendedCompletion ? .success : .rollback, @@ -306,11 +404,57 @@ struct TaskCardView: View { } } +/// What a finished hydration is allowed to write back to the card. +/// +/// `.task(id:)` cancels the in-flight hydration when its key changes, but Swift +/// cancellation is cooperative: the body keeps running and its `await` still +/// returns. A hydration that started while the card had no task can therefore +/// land *after* the reader has ticked that task, carrying an answer from before +/// the tick — and `resolveCanonicalTask` answers nil for any row it cannot +/// vouch for, including one whose owner lease turned over mid-flight. Applying +/// that late nil cleared the retained task and marked hydration finished, which +/// is exactly the pair that renders "Task is no longer available" under a task +/// the reader had just completed. +/// +/// Observed directly: a card visibly showing its task logged +/// `hydrated resolved=nil` from a hydration still in flight behind it. +enum ChatFirstTaskCardHydration { + enum Resolution: Equatable { + /// Nothing newer arrived; the answer is the card's state. + case adopt + /// The card already has a live task, so there is nothing to adopt — but + /// this hydration is genuinely over. + case settle + /// A successor hydration owns the card's state. Write nothing at all: + /// even `hydrationFinished` would flash the unavailable placeholder in + /// the gap before the successor answers. + case abandon + } + + static func resolution(isCancelled: Bool, hasLiveTask: Bool) -> Resolution { + if isCancelled { return .abandon } + return hasLiveTask ? .settle : .adopt + } +} + enum ChatFirstTaskCardPresentation { + /// `locallyCompletedTask` is the completion the reader performed on this card + /// and it outranks everything, retirement included. + /// + /// Every other input is a projection of store state, and store state can say + /// a task is gone for reasons that have nothing to do with the reader: the + /// Removed lane used to tombstone live rows locally, so ticking one of them + /// swapped their own completed card for "Task is no longer available". A + /// gesture the app accepted is not something a later read gets to deny — the + /// card keeps showing the tick until the reader themselves unticks it. static func displayTask( liveTask: TaskActionItem?, - retainedCompletedTask: TaskActionItem? + retainedCompletedTask: TaskActionItem?, + locallyCompletedTask: TaskActionItem? = nil ) -> TaskActionItem? { + if let locallyCompletedTask, locallyCompletedTask.completed { + return locallyCompletedTask + } if let liveTask { return liveTask.isRetired ? nil : liveTask } @@ -640,6 +784,39 @@ enum ChatFirstConversationLinkPolicy { } } +/// Where a chat citation for a conversation opens, decided from the record the +/// server returns for the cited ID. +enum ChatFirstConversationCitationRoute: Equatable { + /// An Omi-device capture. The capture focus routes through the capture + /// archive, which carries the transcript moment into playback. + case captureFocus(momentTs: TimeInterval?) + /// Any other recorded conversation — a desktop or phone session the agent + /// retrieved. Opens as the exact fetched record, which the paginated + /// Conversations list may not currently contain. + case exactRecord +} + +extension ChatFirstConversationLinkPolicy { + /// Chat citations name whatever conversation the agent retrieved, but the + /// capture focus resolves only through the archive's strictly source-scoped + /// fetch — routing a non-capture citation there landed the reader on the + /// Conversations list with nothing opened. Let the fetched record's own + /// provenance pick the route instead of the citation's kind alone. + static func citationRoute( + forFetched conversation: ServerConversation?, + requestedID: String, + momentTimestampMs: Int? + ) -> ChatFirstConversationCitationRoute? { + guard let conversation = validatedConversation(conversation, requestedID: requestedID) else { + return nil + } + if conversation.isOmiCaptureArchiveRecord { + return .captureFocus(momentTs: momentTimestampMs.map { TimeInterval($0) / 1_000 }) + } + return .exactRecord + } +} + struct MemoryLinkView: View { let memoryID: String let summary: String diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index b691f53521d..3ddaf77ac75 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -1,9 +1,10 @@ import Foundation -/// Explicit rendering capability for persisted chat-first blocks. The journal -/// is shared by every Chat surface, but rich controls belong exclusively to -/// the enabled main-window shell. Passing this context is therefore a -/// rendering capability, not a second transcript or a user-controlled flag. +/// The owners a persisted content block needs to become an interactable +/// control: typed navigation, the task store it checks off against, the one +/// chat provider, the canonical goals projection, and the prompt-materialization +/// coordinator. Every Chat surface has one — it is not a capability flag and it +/// is never a second transcript. @MainActor struct ChatFirstRichBlockContext { let navigation: ChatFirstShellNavigation @@ -26,3 +27,30 @@ struct ChatFirstRichBlockContext { self.promptMaterializationCoordinator = promptMaterializationCoordinator } } + +@MainActor +extension ChatFirstRichBlockContext { + /// The context for a Chat surface that is not the main-window shell — the task + /// panel and the floating/notch renderers. They own no navigation or goal + /// state, so they bind the shell's process-wide owners: a card tapped in the + /// notch routes the main window instead of a private copy of it. + /// The auxiliary context for a surface that has no `ChatProvider` in hand — + /// the floating bar and the notch, which render over `ChatProvider.mainInstance` + /// (INV-6: there is no second provider to fall back to). Nil only before the + /// main window has created it, which is also the only moment those surfaces + /// have no transcript to project. + static var floatingSurface: ChatFirstRichBlockContext? { + guard let provider = ChatProvider.mainInstance else { return nil } + return auxiliary(chatProvider: provider) + } + + static func auxiliary(chatProvider: ChatProvider) -> ChatFirstRichBlockContext { + ChatFirstRichBlockContext( + navigation: .shared, + tasksStore: .shared, + chatProvider: chatProvider, + canonicalGoalsStore: .shared, + promptMaterializationCoordinator: .shared + ) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift new file mode 100644 index 00000000000..2bdffa51769 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +/// The one renderer for the six interactable content-block kinds. +/// +/// Main chat, the task panel, and the floating/notch surfaces all project the +/// same journal, so they all render the same cards. This view is what keeps +/// that literal: each host hands it a grouped block plus the message it came +/// from, and gets the same control back. A host that "does not opt into rich +/// controls" used to mean a card silently became `EmptyView` — a turn that read +/// as an empty reply on one surface and a task you could tick off on another. +struct ChatFirstRichBlockGroupView: View { + let group: ContentBlockGroup + /// Identity of the message the block belongs to. `isQuestionCardActionable` + /// is a tail-of-transcript question, so it needs the row, not just the block. + let messageID: String + let context: ChatFirstRichBlockContext + + var body: some View { + switch group { + case .questionCard(_, let questionID, let text, let options, let selectedOptionID): + QuestionCardView( + questionID: questionID, + text: text, + options: options, + selectedOptionID: selectedOptionID, + isActionable: context.chatProvider.isQuestionCardActionable( + messageID: messageID, + questionID: questionID, + selectedOptionID: selectedOptionID + ), + isCapabilityAvailable: context.chatProvider.hasChatFirstMainChatCapability(), + onSelect: { optionID, isDeferral in + Task { @MainActor in + AnalyticsManager.shared.chatFirst( + .question(lifecycle: isDeferral ? .deferred : .answered) + ) + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .questionCard, outcome: .acted, action: .select) + ) + await context.chatProvider.selectQuestionCardOption( + questionID: questionID, + optionID: optionID + ) + } + } + ) + case .taskCard(_, let taskID): + TaskCardView( + taskID: taskID, + tasksStore: context.tasksStore, + navigation: context.navigation + ) + case .goalLink(_, let goalID, let summary): + GoalLinkView( + goalID: goalID, + summary: summary, + navigation: context.navigation, + goalsStore: context.canonicalGoalsStore + ) + case .captureLink(_, let conversationID, let momentTimestampMs, let summary): + CaptureLinkView( + conversationID: conversationID, + momentTimestampMs: momentTimestampMs, + summary: summary, + navigation: context.navigation + ) + case .conversationLink(_, let conversationID, let summary, let recommendedActionItems): + ConversationLinkView( + conversationID: conversationID, + summary: summary, + recommendedActionItems: recommendedActionItems, + navigation: context.navigation + ) + case .memoryLink(_, let memoryID, let summary): + MemoryLinkView( + memoryID: memoryID, + summary: summary, + navigation: context.navigation + ) + case .text, .commentary, .toolCalls, .thinking, .discoveryCard, .agentSpawn, .agentCompletion, + .memoryReviewCard, .followUp: + // Not this view's kinds — the review card and the follow-up chip are + // rendered by the bubble itself. Exhaustive rather than a `default` so a + // block added later has to state its answer here instead of vanishing. + EmptyView() + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift index 4637b86e2a7..340557e4e25 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift @@ -33,6 +33,11 @@ extension APIClient: CanonicalGoalsClient {} /// local rollout preference or re-decides the cohort from cached goal data. @MainActor final class CanonicalGoalsStore: ObservableObject { + /// The one projection. `ViewModelContainer` binds it, and auxiliary Chat + /// surfaces reuse it so a goal link resolves against the same store the shell + /// activated rather than an inert second one. + static let shared = CanonicalGoalsStore() + enum Availability: Equatable { case inactive case loading diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift index cc650770f8f..3370dee8a75 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift @@ -53,7 +53,11 @@ private enum CaptureArchiveRepositoryError: Error { } extension ServerConversation { - fileprivate var isOmiCaptureArchiveRecord: Bool { + /// The archive's provenance contract. Beyond the repository itself, the only + /// legitimate reader is citation routing: a chat citation names whatever the + /// agent retrieved, and this predicate decides whether the capture focus may + /// carry it or it must open as the exact fetched record. + var isOmiCaptureArchiveRecord: Bool { source == .omi && !discarded && (status == .completed || status == .processing) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift index 7bc981f634a..77923852a6b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -24,6 +24,11 @@ enum ChatFirstPromptMaterializationPolicy { /// state remain on the backend/kernel respectively. @MainActor final class ChatFirstPromptMaterializationCoordinator: ObservableObject { + /// The one coordinator. Auxiliary Chat surfaces bind it so they cannot start a + /// second materialization lane; only the mounted main transcript ever reports + /// its first page to it. + static let shared = ChatFirstPromptMaterializationCoordinator() + private var driver: (any ChatFirstPromptMaterializationDriving)? private var didLoadTranscriptFirstPage = false private var lastAttemptAt: Date? diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 5cc0dde9ef4..de5ba670b06 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation @@ -82,6 +83,10 @@ enum ChatFirstRoute: Hashable, Codable, Sendable { let normalized = target.lowercased().replacingOccurrences(of: "-", with: "_") switch normalized { case "dashboard", "home": return .chat + // `help` used to name a "Help from Founder" page no shell mounted. Getting + // help from a person lives in Settings → About (the Community card), so the + // legacy name resolves to the destination that actually exists. + case "help": return .more(.settings) case "rewind": return .more(.rewind) case "apps", "integrations": return .more(.apps) case "permissions": return .more(.permissions) @@ -91,6 +96,14 @@ enum ChatFirstRoute: Hashable, Codable, Sendable { } } +extension ChatFirstRoute { + /// True for the automation names that mean "get help from a person". The root + /// pre-selects the About section for these before routing to Settings. + static func isHelpAutomationTarget(_ target: String) -> Bool { + target.lowercased().replacingOccurrences(of: "-", with: "_") == "help" + } +} + enum ChatFirstMorePage: String, CaseIterable, Codable, Hashable, Sendable { case dashboard case rewind @@ -189,6 +202,11 @@ private struct ChatFirstPersistedNavigation: Codable, Equatable { final class ChatFirstShellNavigation: ObservableObject { static let storageKey = "chatFirstShell.windowNavigation.v1" + /// The one navigation owner. The main window binds it, and the auxiliary Chat + /// surfaces (task panel, floating/notch) bind the same instance so a content + /// block tapped anywhere routes the single shell rather than a private copy. + static let shared = ChatFirstShellNavigation() + @Published private(set) var route: ChatFirstRoute /// The destination currently mounted by SwiftUI. This is deliberately /// separate from `route`: navigation commands are not complete until the @@ -313,6 +331,7 @@ final class ChatFirstShellNavigation: ObservableObject { /// navigation; no legacy page can receive a pending focus. func open(focus: ChatFirstPendingFocus, destination: ChatFirstRoute) { guard destination.isPrimaryDestination else { return } + presentMainWindowIfNeeded() pendingConversation = nil invalidateLinkResolutions() route = destination @@ -338,6 +357,7 @@ final class ChatFirstShellNavigation: ObservableObject { func open(conversation: ServerConversation, destination: ChatFirstRoute) { guard destination.isPrimaryDestination else { return } guard !conversation.id.isEmpty else { return } + presentMainWindowIfNeeded() invalidateLinkResolutions() route = destination visibleRoute = nil @@ -469,6 +489,19 @@ final class ChatFirstShellNavigation: ObservableObject { } } + /// A typed deep link can originate from a surface that is not the main window + /// (a content block in the notch or the task panel). Bring the window forward + /// so the destination this call selects is actually on screen. Already-key is + /// the common case and stays a no-op. + private func presentMainWindowIfNeeded() { + // `NSApp` is an implicitly unwrapped optional and is genuinely nil in a unit + // test host, so it is read through an explicit optional rather than touched. + let application: NSApplication? = NSApp + guard let application else { return } + if let window = application.mainWindow, window.isKeyWindow, window.isVisible { return } + AppDelegate.summonWindowTarget()?.openMainAppWindow() + } + private func persistNavigation() { let persisted = ChatFirstPersistedNavigation(route: route, isSidebarCollapsed: isSidebarCollapsed) defaults.set(try? JSONEncoder().encode(persisted), forKey: Self.storageKey) @@ -502,30 +535,14 @@ final class ChatFirstShellNavigation: ObservableObject { } -/// An immutable per-root sampling result. A failed, missing, stale, or -/// owner-mismatched control response resolves to legacy. Once resolved for an -/// owner it never live-swaps; owner replacement fails closed for this launch. -enum ChatFirstShellVariant: Equatable { - case unresolved - case legacy - case chatFirst(ChatFirstCapabilityProjection) - - var projection: ChatFirstCapabilityProjection? { - guard case .chatFirst(let projection) = self else { return nil } - return projection - } - - var stableName: String { - switch self { - case .unresolved: return "loading" - case .legacy: return "legacy" - case .chatFirst: return "chat_first" - } - } -} - -struct ChatFirstShellCapabilitySample: Equatable { - private(set) var variant: ChatFirstShellVariant = .unresolved +/// An immutable per-root sampling result for the server-owned chat-first +/// capability. It never selects a shell — there is exactly one — and only says +/// whether the capability-gated kernel features may engage this launch. A +/// failed, missing, stale, or owner-mismatched control response resolves to +/// capability-off; content blocks still render either way. +struct ChatFirstCapabilitySample: Equatable { + private(set) var isResolved = false + private(set) var projection: ChatFirstCapabilityProjection? private(set) var sampledOwnerID: String? mutating func resolve( @@ -533,29 +550,30 @@ struct ChatFirstShellCapabilitySample: Equatable { requestedOwnerID: String?, ownerIsStillCurrent: Bool ) { - guard case .unresolved = variant else { return } + guard !isResolved else { return } + isResolved = true guard let ownerID = requestedOwnerID, !ownerID.isEmpty, ownerIsStillCurrent else { - variant = .legacy + projection = nil return } sampledOwnerID = ownerID - if let control, let projection = ChatFirstCapabilityProjection(control: control) { - variant = .chatFirst(projection) - } else { - variant = .legacy + guard let control else { + projection = nil + return } + projection = ChatFirstCapabilityProjection(control: control) } mutating func ownerDidChange(to ownerID: String?) { guard let sampledOwnerID else { return } guard sampledOwnerID == ownerID else { - variant = .legacy + projection = nil return } } mutating func failClosed() { - variant = .legacy + projection = nil } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index fbe2b7efa51..6cf405b807b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -8,10 +8,13 @@ struct ChatFirstShell: View { @ObservedObject var navigation: ChatFirstShellNavigation let appState: AppState let viewModelContainer: ViewModelContainer - let capability: ChatFirstCapabilityProjection + /// Nil until the server-owned control resolves, and permanently nil for an + /// account it does not cover. The shell mounts either way; only the + /// capability-gated features below wait on it. + let capability: ChatFirstCapabilityProjection? @Binding var selectedSettingsSection: SettingsContentView.SettingsSection @Binding var highlightedSettingID: String? - @StateObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator() + @ObservedObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator.shared @StateObject private var automationRuntime: ChatFirstAutomationRuntime @AppStorage(MemoryHubDestination.storageKey) private var memoryDestinationRawValue = MemoryHubDestination.memories.rawValue @@ -21,7 +24,7 @@ struct ChatFirstShell: View { navigation: ChatFirstShellNavigation, appState: AppState, viewModelContainer: ViewModelContainer, - capability: ChatFirstCapabilityProjection, + capability: ChatFirstCapabilityProjection?, selectedSettingsSection: Binding<SettingsContentView.SettingsSection>, highlightedSettingID: Binding<String?> ) { @@ -66,7 +69,7 @@ struct ChatFirstShell: View { .environmentObject(navigation) .onAppear { promptMaterializationCoordinator.activate(using: viewModelContainer.chatProvider) - viewModelContainer.canonicalGoalsStore.activate(capability: capability) + activateCapabilityGatedFeatures() automationRuntime.install() syncMemoryDestination(for: navigation.route) syncSettingsSection(for: navigation.route) @@ -75,6 +78,9 @@ struct ChatFirstShell: View { ) } .onDisappear { automationRuntime.uninstall() } + // The capability resolves after the shell is already on screen, so the + // gated features engage here rather than only at mount. + .onChange(of: capability) { _, _ in activateCapabilityGatedFeatures() } .onChange(of: navigation.route) { _, route in syncMemoryDestination(for: route) syncSettingsSection(for: route) @@ -114,6 +120,11 @@ struct ChatFirstShell: View { } } + private func activateCapabilityGatedFeatures() { + guard let capability else { return } + viewModelContainer.canonicalGoalsStore.activate(capability: capability) + } + private var isMainWindowForeground: Bool { guard NSApp.isActive, let window = NSApp.mainWindow else { return false } return window.isKeyWindow && window.isVisible @@ -204,9 +215,7 @@ struct ChatFirstShell: View { chatProvider: viewModelContainer.chatProvider, memoriesViewModel: viewModelContainer.memoriesViewModel, taskChatCoordinator: viewModelContainer.taskChatCoordinator, - forceModernPresentation: true, - chatFirstRichBlockContext: richBlockContext, - selectedIndex: legacySelectionBinding + chatFirstRichBlockContext: richBlockContext ) } @@ -331,36 +340,6 @@ struct ChatFirstShell: View { } } - /// Existing Dashboard callbacks still speak in legacy sidebar items. Keep - /// that compatibility at this one boundary while the Chat-first shell itself is - /// entirely route-typed. - private var legacySelectionBinding: Binding<Int> { - Binding( - get: { legacySidebarItem(for: navigation.route).rawValue }, - set: { rawValue in - guard let item = SidebarNavItem(rawValue: rawValue) else { return } - navigation.selectLegacyDestination(item) - } - ) - } - - private func legacySidebarItem(for route: ChatFirstRoute) -> SidebarNavItem { - switch route { - case .chat: return .dashboard - case .conversations: return .conversations - case .tasks: return .tasks - case .memories: return .memories - case .goals: return .dashboard - case .more(let page): - switch page { - case .dashboard: return .dashboard - case .rewind: return .rewind - case .apps: return .apps - case .permissions: return .permissions - case .settings: return .settings - } - } - } } /// Chat-first passes through every destination that owns search/content panels. Older single-panel diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 70b022902ab..bcf906ac99c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -6,6 +6,26 @@ enum ChatBubbleMetadataControlMetrics { static let leadingInset = OmiSpacing.xxs static let topInset = leadingInset static let targetSize: CGFloat = 24 + + /// What an assistant row reserves under its last line for the hover strip. + /// The transcript's row spacing subtracts it, so the gap under a reply is the + /// band itself rather than the band *plus* a full inter-exchange gap. + static let bandHeight: CGFloat = topInset + targetSize +} + +/// `.keyboardShortcut` is unconditional on a `Button`, so the guard has to be +/// the modifier's presence rather than an argument to it. +struct ChatCopyKeyboardShortcut: ViewModifier { + let isActive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isActive { + content.keyboardShortcut("c", modifiers: .command) + } else { + content + } + } } enum ChatBubbleMetadataHoverRegion { @@ -64,9 +84,10 @@ struct ChatBubble: View { var onCancelTurn: (() -> Void)? = nil var onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil var onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil - /// Nil for all existing Chat surfaces. Rich blocks are transcript data, but - /// only the capability-gated main shell is allowed to turn them into controls. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + /// The owners a content block needs to become an interactable control. Every + /// Chat surface has one — a rendered card is transcript data either way, and a + /// card the reader cannot act on is worse than no card at all. + let chatFirstRichBlockContext: ChatFirstRichBlockContext var metadataRevealOverrideForTesting: Bool? = nil @State private var metadataHoverState = ChatBubbleMetadataHoverState() @State private var isExpanded = false @@ -94,7 +115,7 @@ struct ChatBubble: View { onCancelTurn: (() -> Void)? = nil, onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil, onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil, - chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + chatFirstRichBlockContext: ChatFirstRichBlockContext ) { self.message = message self.app = app @@ -110,8 +131,23 @@ struct ChatBubble: View { _lastSubmittedRating = State(initialValue: message.rating) } - /// Messages longer than this are truncated with a "Show more" button - private static let truncationThreshold = ChatBubbleTruncation.threshold + /// The transcript's visible size, so a long reply is folded in screens of + /// text rather than a fixed count of characters. + @Environment(\.chatTranscriptViewport) private var transcriptViewport + @Environment(\.fontScale) private var truncationFontScale + + /// How much of this reply shows before "Show more": two screens of prose at + /// the column this row actually renders in. + private var truncationBudget: ChatBubbleTruncation.Budget { + let column = + transcriptViewport.width > 0 + ? min(Self.messageColumnMaxWidth, transcriptViewport.width) + : Self.messageColumnMaxWidth + return ChatBubbleTruncation.budget( + viewportHeight: transcriptViewport.height, + columnWidth: column, + fontScale: truncationFontScale) + } /// Readable width shared by the bubble and its metadata row. Keeping this /// explicit lets the metadata row expand to the message column even when @@ -123,7 +159,8 @@ struct ChatBubble: View { ChatBubbleTruncation.shouldTruncate( text: bubbleText, isStreaming: message.isStreaming, - isExpanded: isExpanded + isExpanded: isExpanded, + budget: truncationBudget ) } @@ -140,7 +177,8 @@ struct ChatBubble: View { ChatBubbleTruncation.displayText( bubbleText, isStreaming: message.isStreaming, - isExpanded: isExpanded + isExpanded: isExpanded, + budget: truncationBudget ) } @@ -177,8 +215,7 @@ struct ChatBubble: View { } else { let groupedBlocks = ContentBlockGroup.visibleChatGroups( message.contentBlocks, - isStreaming: message.isStreaming, - richBlockRenderingEnabled: chatFirstRichBlockContext != nil + isStreaming: message.isStreaming ) HStack(alignment: .top, spacing: OmiSpacing.md) { @@ -222,11 +259,18 @@ struct ChatBubble: View { .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) } } + // The reserved mark height is for **an empty streaming reply**, which has no + // content of its own and would otherwise clip the mark. A settled row is + // always taller than the mark, so reserving it there only centred short + // content — a one-line answer or a memory card — inside a 32 pt box and + // floated it in symmetric dead space. .frame( maxWidth: .infinity, - minHeight: ChatOmiMarkPlacement.rowHeight( - showsMark: message.sender == .ai && app == nil && showsOmiMark), - alignment: message.sender == .user ? .trailing : .leading + minHeight: message.isStreaming + ? ChatOmiMarkPlacement.rowHeight( + showsMark: message.sender == .ai && app == nil && showsOmiMark) + : 0, + alignment: message.sender == .user ? .topTrailing : .topLeading ) .overlay(alignment: .topLeading) { if message.sender == .ai, app == nil, showsOmiMark { @@ -246,7 +290,44 @@ struct ChatBubble: View { } } .contentShape(Rectangle()) + .onChange(of: message.isStreaming) { wasStreaming, isStreaming in + guard + ChatBubbleTruncation.settlingKeepsFullBody( + wasStreaming: wasStreaming, isStreaming: isStreaming) + else { return } + isExpanded = true + } .onHover { updateMetadataHover(.row, hovering: $0) } + // Copy without hunting for the hover strip — and the only copy affordance a + // user turn has ever had. + .contextMenu { messageContextMenu } + .accessibilityElement(children: .contain) + .accessibilityLabel(message.sender == .user ? "You" : "Omi") + } + + /// The text the row's copy actions put on the pasteboard. `copyableText` + /// excludes pre-tool commentary, but it is empty for a user turn, whose whole + /// body is the message. + private var copyPayload: String { + message.copyableText.isEmpty ? message.text : message.copyableText + } + + private func copyMessageToPasteboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(copyPayload, forType: .string) + showCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + showCopied = false + } + } + + @ViewBuilder + private var messageContextMenu: some View { + if !copyPayload.isEmpty { + // Selecting is done in the words themselves now; this stays for the + // whole message, which a drag would have to be exact to reproduce. + Button("Copy Message") { copyMessageToPasteboard() } + } } @ViewBuilder @@ -347,13 +428,23 @@ struct ChatBubble: View { // `ChatTurnFailureNotice`). The blanket "Couldn't save this reply" caption // both duplicated that reason in different words and named the wrong // cause — the turn failed, no save was attempted. Keep a stamp only for a - // failed row that has nothing of its own to say. - if message.sender == .ai && !message.isStreaming && message.journalStatus == .failed - && message.text.isEmpty && message.contentBlocks.isEmpty - { + // failed row that has nothing of its own to say — and, for a row that was + // cut off mid-sentence, a quiet mark so the reader can see it was cut. + switch ChatTurnFailurePresentation.of(message) { + case .none: + EmptyView() + case .emptyTurnStamp: Text("This turn didn't finish") .scaledFont(size: OmiType.micro, weight: .medium) .foregroundColor(PageGlass.warning) + case .truncatedAnswer: + HStack(spacing: OmiSpacing.xxs) { + Text("\u{2026}") + .scaledFont(size: OmiType.caption, weight: .semibold) + Text("Interrupted") + .scaledFont(size: OmiType.micro, weight: .medium) + } + .foregroundColor(Ink.secondary) } switch ChatBubbleMetadataBand.of(message) { @@ -373,14 +464,10 @@ struct ChatBubble: View { ChatResourceActions.open(resource) return } - if let chatFirstRichBlockContext { - let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } - chatFirstRichBlockContext.navigation.open( - focus: .capture(id: reference.sourceID, momentTs: moment) - ) - return - } - onOpenInlineCitation?(reference.navigationReference) + let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } + chatFirstRichBlockContext.navigation.open( + focus: .capture(id: reference.sourceID, momentTs: moment) + ) } private var presentation: ChatRowPresentation { ChatRowPresentation.of(message) } @@ -400,7 +487,8 @@ struct ChatBubble: View { text: text, sender: message.sender, citations: citationReferencesForThisSurface, - onOpenCitation: onOpenInlineCitation + onOpenCitation: onOpenInlineCitation, + appKitProseSelection: true ) .chatMessageBlock(filled: presentation.isFilled) } @@ -418,7 +506,7 @@ struct ChatBubble: View { @ViewBuilder private var truncationControl: some View { - if backgroundAgentSummary == nil, bubbleText.count > Self.truncationThreshold { + if backgroundAgentSummary == nil, ChatBubbleTruncation.exceedsBudget(bubbleText, budget: truncationBudget) { if isExpanded { Button(action: { isExpanded.toggle() }) { Text("Show less") @@ -450,7 +538,8 @@ struct ChatBubble: View { text: text, sender: .ai, citations: citationReferencesForThisSurface, - onOpenCitation: onOpenInlineCitation + onOpenCitation: onOpenInlineCitation, + appKitProseSelection: true ) .chatMessageBlock(filled: false)) case .commentary(_, let text): @@ -472,87 +561,18 @@ struct ChatBubble: View { return AnyView(EmptyView()) case .discoveryCard(_, let title, let summary, let fullText): return AnyView(DiscoveryCard(title: title, summary: summary, fullText: fullText)) - case .questionCard(_, let questionID, let text, let options, let selectedOptionID): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - QuestionCardView( - questionID: questionID, - text: text, - options: options, - selectedOptionID: selectedOptionID, - isActionable: chatFirstRichBlockContext.chatProvider.isQuestionCardActionable( - messageID: message.id, - questionID: questionID, - selectedOptionID: selectedOptionID - ), - onSelect: { optionID, isDeferral in - Task { @MainActor in - AnalyticsManager.shared.chatFirst( - .question(lifecycle: isDeferral ? .deferred : .answered) - ) - AnalyticsManager.shared.chatFirst( - .richBlock(kind: .questionCard, outcome: .acted, action: .select) - ) - await chatFirstRichBlockContext.chatProvider.selectQuestionCardOption( - questionID: questionID, - optionID: optionID - ) - } - } - ) - ) - case .taskCard(_, let taskID): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink: + // One renderer for all six, shared with the task panel and the notch. return AnyView( - TaskCardView( - taskID: taskID, - tasksStore: chatFirstRichBlockContext.tasksStore, - navigation: chatFirstRichBlockContext.navigation - ) - ) - case .goalLink(_, let goalID, let summary): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - GoalLinkView( - goalID: goalID, - summary: summary, - navigation: chatFirstRichBlockContext.navigation, - goalsStore: chatFirstRichBlockContext.canonicalGoalsStore - ) - ) - case .captureLink(_, let conversationID, let momentTimestampMs, let summary): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - CaptureLinkView( - conversationID: conversationID, - momentTimestampMs: momentTimestampMs, - summary: summary, - navigation: chatFirstRichBlockContext.navigation - ) - ) - case .conversationLink(_, let conversationID, let summary, let recommendedActionItems): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - ConversationLinkView( - conversationID: conversationID, - summary: summary, - recommendedActionItems: recommendedActionItems, - navigation: chatFirstRichBlockContext.navigation - ) - ) - case .memoryLink(_, let memoryID, let summary): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - MemoryLinkView( - memoryID: memoryID, - summary: summary, - navigation: chatFirstRichBlockContext.navigation + ChatFirstRichBlockGroupView( + group: group, + messageID: message.id, + context: chatFirstRichBlockContext ) ) case .memoryReviewCard(_, let summaryID, let date, let items): return AnyView(MemoryReviewCardView(summaryID: summaryID, date: date, items: items)) case .followUp(_, let question): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } let provider = chatFirstRichBlockContext.chatProvider return AnyView( FollowUpChip( @@ -642,6 +662,9 @@ struct ChatBubble: View { .onHover { updateMetadataHover(.controls, hovering: $0) } .opacity(isVisible ? 1 : 0) .allowsHitTesting(isVisible) + // Opacity and hit-testing hide the strip from the eye and the mouse; without + // this VoiceOver still walked through invisible thumbs and a copy button. + .accessibilityHidden(!isVisible) .omiAnimation(.easeInOut(duration: 0.15), value: isVisible) } @@ -750,14 +773,7 @@ struct ChatBubble: View { @ViewBuilder private var copyButton: some View { - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(message.copyableText, forType: .string) - showCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - showCopied = false - } - }) { + Button(action: copyMessageToPasteboard) { Image(systemName: showCopied ? "checkmark" : "doc.on.doc") .scaledFont(size: OmiType.caption) .foregroundColor(showCopied ? Ink.listeningGreen : Ink.secondary) @@ -769,6 +785,9 @@ struct ChatBubble: View { } .buttonStyle(.plain) .focused($isMetadataControlFocused) + // Only while this row's control strip holds keyboard focus. A window-wide + // ⌘C would take the shortcut away from selected prose and the composer. + .modifier(ChatCopyKeyboardShortcut(isActive: isMetadataControlFocused)) .help("Copy message") } @@ -1208,10 +1227,7 @@ enum ContentBlockGroup: Identifiable { } /// Groups consecutive `.toolCall` blocks together; passes other blocks through - static func group( - _ blocks: [ChatContentBlock], - richBlockRenderingEnabled: Bool = false - ) -> [ContentBlockGroup] { + static func group(_ blocks: [ChatContentBlock]) -> [ContentBlockGroup] { var groups: [ContentBlockGroup] = [] var pendingToolCalls: [ChatContentBlock] = [] @@ -1237,21 +1253,17 @@ enum ContentBlockGroup: Identifiable { groups.append(.discoveryCard(id: id, title: title, summary: summary, fullText: fullText)) case .questionCard(let id, let questionID, let text, _, _, let options, let selectedOptionID): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append( .questionCard( id: id, questionID: questionID, text: text, options: options, selectedOptionID: selectedOptionID)) case .taskCard(let id, let taskID): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append(.taskCard(id: id, taskID: taskID)) case .goalLink(let id, let goalID, let summary): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append(.goalLink(id: id, goalID: goalID, summary: summary)) case .captureLink(let id, let conversationID, let momentTimestampMs, let summary): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append( .captureLink( id: id, @@ -1262,7 +1274,6 @@ enum ContentBlockGroup: Identifiable { ) case .conversationLink(let id, let conversationID, let summary, let recommendedActionItems): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append( .conversationLink( id: id, @@ -1271,7 +1282,6 @@ enum ContentBlockGroup: Identifiable { recommendedActionItems: recommendedActionItems)) case .memoryLink(let id, let memoryID, let summary): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append(.memoryLink(id: id, memoryID: memoryID, summary: summary)) case .memoryReviewCard(let id, let summaryID, let date, let items): flushToolCalls() @@ -1326,8 +1336,7 @@ enum ContentBlockGroup: Identifiable { /// A structured `.agentSpawn` replaces only its duplicate raw spawn call (INV-6 structured identity). static func visibleChatGroups( _ blocks: [ChatContentBlock], - isStreaming: Bool, - richBlockRenderingEnabled: Bool = false + isStreaming: Bool ) -> [ContentBlockGroup] { // The display projection turns a persisted spawn into its terminal card. // Both structured forms are therefore authoritative evidence that the @@ -1352,7 +1361,7 @@ enum ContentBlockGroup: Identifiable { return trimmedRun.isEmpty ? nil : "run:\(trimmedRun)" } ) - let grouped = group(blocks, richBlockRenderingEnabled: richBlockRenderingEnabled) + let grouped = group(blocks) let lastToolIndex = grouped.lastIndex { group in if case .toolCalls = group { return true } return false diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift index 30760c5413e..c31813ea958 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift @@ -4,18 +4,132 @@ import SwiftUI /// Pure collapsed-body policy so the transcript's truncation contract can be /// covered without requiring a running SwiftUI window. +/// +/// The budget is measured in viewports of rendered text, not characters. Five +/// hundred characters was five lines: every real answer collapsed, and the +/// reader clicked "Show more" under nearly everything they asked. A reply may +/// now fill `viewportHeightsBeforeCollapse` screens before the transcript +/// offers to fold it, and a reply that long starts folded — restored history +/// should not be mostly one old answer. enum ChatBubbleTruncation { - static let threshold = 500 + /// How many screens of prose a reply may take before it is offered collapsed. + static let viewportHeightsBeforeCollapse: CGFloat = 2 + /// Stands in until the transcript has measured itself, and in tests. + static let fallbackViewportHeight: CGFloat = 720 + static let fallbackColumnWidth: CGFloat = 640 + /// A collapsed body is never shorter than this many lines, whatever the + /// window: a few lines and "Show more" is a teaser, not a message. + static let minimumLines = 12 + + /// What the collapsed body may hold, derived from the transcript's geometry. + struct Budget: Equatable { + /// Rendered lines the collapsed body may take. + let lines: Int + /// Characters one full line of prose holds at this width. + let charactersPerLine: Int + + static let fallback = ChatBubbleTruncation.budget( + viewportHeight: fallbackViewportHeight, columnWidth: fallbackColumnWidth) + } - static func shouldTruncate(text: String, isStreaming: Bool, isExpanded: Bool) -> Bool { - !isStreaming && text.count > threshold && !isExpanded + /// - Parameters: + /// - viewportHeight: the transcript's visible height; `0` before it is measured. + /// - columnWidth: the message column; `0` before it is measured. + /// - fontScale: the reader's text-size preference, as the prose renders it. + static func budget(viewportHeight: CGFloat, columnWidth: CGFloat, fontScale: CGFloat = 1) -> Budget { + let fontSize = round(14 * max(fontScale, 0.5)) + let lineHeight = fontSize * 1.25 + OmiMarkdownContent.chatLineSpacing(fontSize: fontSize) + let height = viewportHeight > 0 ? viewportHeight : fallbackViewportHeight + let width = columnWidth > 0 ? columnWidth : fallbackColumnWidth + // SF at text sizes averages about half an em per glyph, and prose wraps + // before the edge, so a line holds a bit less than the width allows. + let charactersPerLine = max(20, Int((width / (fontSize * 0.5)) * 0.85)) + let lines = max(minimumLines, Int((height * viewportHeightsBeforeCollapse) / lineHeight)) + return Budget(lines: lines, charactersPerLine: charactersPerLine) } - static func displayText(_ text: String, isStreaming: Bool, isExpanded: Bool) -> String { - guard shouldTruncate(text: text, isStreaming: isStreaming, isExpanded: isExpanded) else { - return text + /// Lines the text takes when wrapped at `charactersPerLine`: each source line + /// wraps on its own, and a blank one is the small gap between paragraphs. + static func estimatedLines(_ text: String, charactersPerLine: Int) -> Double { + text.split(separator: "\n", omittingEmptySubsequences: false).reduce(0) { total, line in + total + lineCost(line, charactersPerLine: charactersPerLine) } - return String(text.prefix(threshold)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" + } + + private static func lineCost(_ line: Substring, charactersPerLine: Int) -> Double { + let count = line.trimmingCharacters(in: .whitespaces).count + guard count > 0 else { return 0.35 } + return max(1, (Double(count) / Double(max(charactersPerLine, 1))).rounded(.up)) + } + + static func exceedsBudget(_ text: String, budget: Budget) -> Bool { + estimatedLines(text, charactersPerLine: budget.charactersPerLine) > Double(budget.lines) + } + + /// Whether an answer that has just finished streaming keeps its full body. + /// + /// Truncation is for restored history — a long transcript should not be + /// mostly one old reply. An answer the reader just watched arrive is the + /// opposite case: clamping it at the moment it settles takes back everything + /// they read, and shrinks the document by thousands of points under a + /// transcript that was following the live edge, so the reply they were + /// reading is replaced by its own first paragraph. A forty-item list + /// collapsed to three the instant it finished. + static func settlingKeepsFullBody(wasStreaming: Bool?, isStreaming: Bool?) -> Bool { + wasStreaming == true && isStreaming != true + } + + static func shouldTruncate( + text: String, isStreaming: Bool, isExpanded: Bool, budget: Budget = .fallback + ) -> Bool { + !isStreaming && !isExpanded && exceedsBudget(text, budget: budget) + } + + static func displayText( + _ text: String, isStreaming: Bool, isExpanded: Bool, budget: Budget = .fallback + ) -> String { + guard shouldTruncate(text: text, isStreaming: isStreaming, isExpanded: isExpanded, budget: budget) + else { return text } + return collapsedPrefix(text, budget: budget) + "…" + } + + /// The first `budget.lines` rendered lines, cut at a source line where it can + /// be — mid-word cuts read as damage — and by characters only inside a single + /// paragraph too long to fit. A fence opened inside the kept prefix is closed + /// so the hidden remainder does not turn the ellipsis into code. + static func collapsedPrefix(_ text: String, budget: Budget) -> String { + var remaining = Double(budget.lines) + var kept = [Substring]() + var fenceOpen = false + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let cost = lineCost(line, charactersPerLine: budget.charactersPerLine) + if cost > remaining { + let characters = Int(remaining.rounded(.down)) * budget.charactersPerLine + if characters > 0 { kept.append(line.prefix(characters)) } + break + } + remaining -= cost + if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") { fenceOpen.toggle() } + kept.append(line) + } + var prefix = kept.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if fenceOpen { prefix += "\n```\n" } + return prefix + } +} + +// MARK: - Transcript viewport + +private struct ChatTranscriptViewportKey: EnvironmentKey { + static let defaultValue: CGSize = .zero +} + +extension EnvironmentValues { + /// The visible size of the transcript a row is drawn in, so a row can size + /// its own collapse budget in screens. `.zero` until the transcript measures. + var chatTranscriptViewport: CGSize { + get { self[ChatTranscriptViewportKey.self] } + set { self[ChatTranscriptViewportKey.self] = newValue } } } @@ -36,7 +150,11 @@ enum ChatAssistantAnswerText { } } - let fallbackText = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + // A body that is only the blocks' own degradation has nothing the cards + // above it do not already say, so it is not answer text at all. + let fallbackText = + ChatStructuredFallbackText.bodyIsBlockProjection(text: fallback, contentBlocks: contentBlocks) + ? "" : fallback.trimmingCharacters(in: .whitespacesAndNewlines) guard let lastTool = contentBlocks.lastIndex(where: { block in if case .toolCall = block { return true } @@ -62,6 +180,85 @@ enum ChatAssistantAnswerText { } } +/// The unaware-client projection of a turn's structured blocks. +/// +/// A turn that answers with cards writes no prose, so the runtime synthesizes +/// one line per block — "Goal - Make Omi Great Again", the bare word "Task" +/// once per task card — and puts it on the message's ordinary text field. That +/// is the degradation contract for clients that cannot draw the cards +/// (`agent/src/runtime/content-block-fallback.ts`). A client that *does* draw +/// them must recognize its own projection and not print it back underneath the +/// controls it just rendered. Mobile recognizes it the same way, in +/// `ServerMessage.textIsStructuredFallback`; the two must agree, so this +/// mirrors the producer case for case. +enum ChatStructuredFallbackText { + static func bodyIsBlockProjection(text: String, contentBlocks: [ChatContentBlock]) -> Bool { + guard !contentBlocks.isEmpty else { return false } + let projection = projected(contentBlocks) + guard !projection.isEmpty else { return false } + let body = text.trimmingCharacters(in: .whitespacesAndNewlines) + return body.isEmpty || normalized(body) == normalized(projection) + } + + static func projected(_ contentBlocks: [ChatContentBlock]) -> String { + contentBlocks.map(line(for:)).filter { !$0.isEmpty }.joined(separator: "\n") + } + + private static func normalized(_ value: String) -> String { + value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + private static func labelled(_ label: String, _ details: String?...) -> String { + var unique: [String] = [] + for detail in details { + let trimmed = detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, !unique.contains(trimmed) else { continue } + unique.append(trimmed) + } + return unique.isEmpty ? label : "\(label) - \(unique.joined(separator: " - "))" + } + + private static func nonEmpty(_ value: String, or fallback: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + + private static func line(for block: ChatContentBlock) -> String { + switch block { + case .text(_, let text): + return nonEmpty(text, or: "Message") + case .toolCall(_, let name, _, _, let input, let output): + return labelled("Tool", name, output ?? input?.summary) + case .thinking(_, let text): + return labelled("Thinking", text) + case .discoveryCard(_, let title, let summary, _): + return labelled("Discovery", title, summary) + case .questionCard(_, _, let text, _, _, _, _): + return nonEmpty(text, or: "Question") + case .taskCard: + return "Task" + case .goalLink(_, _, let summary): + return labelled("Goal", summary) + case .captureLink(_, _, _, let summary): + return labelled("Capture", summary) + case .conversationLink(_, _, let summary, _): + return labelled("Meeting notes ready", summary) + case .memoryLink(_, _, let summary): + return labelled("Memory", summary) + case .citation(_, let reference): + return labelled("Source", reference.title, reference.preview) + case .agentSpawn(_, _, _, _, let title, let objective, _): + return labelled("Agent started", title, objective) + case .agentCompletion(_, _, _, _, let title, _, let output, _): + return labelled("Agent completed", title, output) + case .memoryReviewCard: + return "Memory review" + case .followUp(_, let question): + return nonEmpty(question, or: "Follow-up") + } + } +} + /// Shared understated date treatment for a transcript row and its prompt-rail /// preview. Keeping this outside the bubble makes the time contextual rather /// than part of the message itself. @@ -271,8 +468,17 @@ struct ProactiveNotificationBadge: Equatable { (label, systemImage) = ("Memory", "brain.head.profile") case .integration: (label, systemImage) = ("Integration", "sparkles.rectangle.stack") + case .functional: + (label, systemImage) = ("Omi", "bell") case .general: + // Decode-only: rows journaled before proactive kinds were part of the + // continuity key. No producer can reach it (`showNotification` requires a + // kind), so this arm is history, not a category. (label, systemImage) = ("Notification", "bell") + case .trial, .onboarding: + // Never journaled, so never rendered as a transcript row. Kept exhaustive + // so a future decision to journal them has to state its badge here. + (label, systemImage) = ("Omi", "bell") } } } @@ -332,7 +538,14 @@ enum ChatBubbleMetadataBand: Equatable { static func of(_ message: ChatMessage) -> Self { guard message.sender == .ai, !message.isStreaming else { return .hidden } - guard !message.copyableText.isEmpty else { return .timestampOnly } + guard !message.copyableText.isEmpty else { + // **A row whose whole content is a rich block gets no band.** A memory + // card carries its own header and time on its face; reserving a strip + // for a second timestamp underneath it left the card floating in dead + // space with nothing to copy or rate. A row with nothing at all still + // keeps its timestamp — that stamp is all it has. + return message.contentBlocks.isEmpty ? .timestampOnly : .hidden + } return .actions } } @@ -483,3 +696,27 @@ struct ChatSuggestedTaskRow: View { } } } + +/// **How a turn that stopped mid-sentence tells the reader it was cut off.** +/// +/// A voice barge-in persists whatever the assistant had said so far with a +/// terminal `.failed` status. Before this, a truncated answer rendered exactly +/// like a complete one — "…arrive on Saturday," with nothing to say it was +/// interrupted — because the only failure affordance was a stamp for a row +/// with no text at all. +enum ChatTurnFailurePresentation: Equatable { + /// Not a failed assistant row. + case none + /// The turn failed with nothing to show: the row is the notice. + case emptyTurnStamp + /// The turn failed after saying something: show it, then mark the cut. + case truncatedAnswer + + static func of(_ message: ChatMessage) -> Self { + guard message.sender == .ai, !message.isStreaming, message.journalStatus == .failed else { + return .none + } + guard message.text.isEmpty, message.contentBlocks.isEmpty else { return .truncatedAnswer } + return .emptyTurnStamp + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift index 772ce004e96..62c269306c7 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -25,7 +25,7 @@ enum ChatMessageDeduplicator { var seen: [String: String] = [:] // sender+full-text fingerprint → first message ID var dupes = Set<String>() for msg in messages { - guard msg.text.count > 200 else { continue } // only dedup long messages + guard msg.text.count > 200 else { continue } // replay dedup: long messages only let fingerprint = "\(msg.sender)\u{1}\(msg.text)" if seen[fingerprint] != nil { dupes.insert(msg.id) @@ -33,8 +33,60 @@ enum ChatMessageDeduplicator { seen[fingerprint] = msg.id } } + return dupes.union(adjacentDuplicateIDs(in: messages)) + } + + /// Below this an answer is too small to read as a stutter worth a chip. + static let shortDuplicateMinimumLength = 40 + /// Two rows further apart than this are two occasions, not one repeated one. + static let adjacentDuplicateWindow: TimeInterval = 600 + + /// A short answer repeated back-to-back is the other way the transcript + /// stutters, and the 200-character floor above never caught it: each press of + /// push-to-talk mints a distinct `voice:<uuid>` turn, so three tries at the + /// same question are three legitimate journal rows saying the same ~90 + /// characters. Journal identity is not the place to fix that — this is a + /// display collapse, and it stays behind the expandable "Duplicate message" + /// chip so nothing is ever hidden outright. + /// + /// Adjacency and time proximity are what keep it honest: the same sentence + /// said again tomorrow, or with another exchange in between, is a real answer + /// to a real question and must not collapse. + static func adjacentDuplicateIDs(in messages: [ChatMessage]) -> Set<String> { + var dupes = Set<String>() + for index in messages.indices.dropFirst() { + let previous = messages[index - 1] + let current = messages[index] + guard previous.sender == current.sender else { continue } + guard + abs(current.createdAt.timeIntervalSince(previous.createdAt)) <= adjacentDuplicateWindow + else { continue } + + let earlier = normalizedBody(previous) + let later = normalizedBody(current) + + if earlier == later { + // The floor is on the answer itself: a repeated "Done." is not a stutter. + guard earlier.count >= shortDuplicateMinimumLength else { continue } + dupes.insert(current.id) + } else if previous.journalStatus == .failed, !earlier.isEmpty, later.hasPrefix(earlier), + later.count >= shortDuplicateMinimumLength + { + // A barge-in fragment and the answer it was cut out of. The *fragment* + // is short by definition, so the floor applies to the whole answer. + dupes.insert(previous.id) + } else if current.journalStatus == .failed, !later.isEmpty, earlier.hasPrefix(later), + earlier.count >= shortDuplicateMinimumLength + { + dupes.insert(current.id) + } + } return dupes } + + private static func normalizedBody(_ message: ChatMessage) -> String { + message.text.trimmingCharacters(in: .whitespacesAndNewlines) + } } /// **When duplicate detection has to run again.** @@ -126,6 +178,32 @@ enum ChatInitialRestoreState: Equatable { } } +/// The daily summary card's admission decision, in one place (INV-CHAT-2). +/// +/// The card is chrome above the thread, and chrome must not outrun the thread. +/// Admitting while the initial history is still loading printed the summary +/// alone over a loading spinner, and the reader watched it yank above the fold +/// the moment the transcript landed at the live edge — launch read as +/// "summary page, then chat". The initial load therefore defers admission to +/// the loading-complete observer, which admits before the live-edge restore +/// measures geometry. Once the thread exists, the reader is only moved when +/// they are following the live edge (the re-follow lands them back at the +/// bottom) or when there is nothing to move. +enum ChatDailySummaryAdmission { + static func shouldAdmit( + hasSummary: Bool, + isClearedFromTranscript: Bool, + alreadyAdmitted: Bool, + isLoadingInitial: Bool, + scrollMode: ChatScrollMode, + hasMessages: Bool + ) -> Bool { + guard hasSummary, !isClearedFromTranscript, !alreadyAdmitted else { return false } + guard !isLoadingInitial else { return false } + return scrollMode == .followingBottom || !hasMessages + } +} + /// **The rhythm of the transcript**, which is what makes a column of short /// messages read as a conversation instead of as scattered text. /// @@ -142,6 +220,13 @@ enum ChatTranscriptLayout { /// `topAdjustment`, so the stack has one spacing and the exceptions are named. static let regularRowSpacing: CGFloat = OmiSpacing.lg static let consecutiveUserRowSpacing: CGFloat = OmiSpacing.sm + /// **The gap after a row that reserves its own metadata band.** That band is + /// 28 pt of real, empty layout under the last line, so adding a full + /// inter-exchange gap on top of it charged the reader twice for the same + /// separation — roughly 100 device pixels of nothing between two one-line + /// answers. The band *is* the gap; this is only the hairline that keeps the + /// controls off the next row. + static let afterMetadataBandRowSpacing: CGFloat = OmiSpacing.xxs /// A reply and the question that caused it are one exchange, not two events. /// `md` rather than `sm`: the user bubble's own bottom padding already hugs /// the text, so `sm` left the next assistant line sitting on the bubble. @@ -149,10 +234,11 @@ enum ChatTranscriptLayout { /// The gap *before* `current`, given the row above it. /// - /// An assistant row above always takes the full gap: it closes an exchange, and - /// it is also the row whose hover-revealed metadata band draws into the space - /// below it, so that space has to exist. + /// A row that reserves a metadata band has already paid for the separation in + /// its own height, so it takes the hairline. Everything else follows the + /// exchange ladder. static func spacing(from previous: ChatMessage, to current: ChatMessage) -> CGFloat { + if ChatBubbleMetadataBand.of(previous) != .hidden { return afterMetadataBandRowSpacing } guard previous.sender == .user else { return regularRowSpacing } return current.sender == .user ? consecutiveUserRowSpacing : replySpacing } @@ -323,6 +409,10 @@ enum ChatTranscriptWindow { } } +/// The coarse step at which `ChatMessagesView.rowViewport` republishes. The +/// view is generic, so the constant lives here rather than as a static on it. +private let chatRowViewportStep: CGFloat = 48 + /// Reusable chat messages scroll view extracted from ChatPage. /// Used by both ChatPage (main chat) and TaskChatPanel (task sidebar chat). struct ChatMessagesView<WelcomeContent: View>: View { @@ -359,13 +449,14 @@ struct ChatMessagesView<WelcomeContent: View>: View { /// Horizontal inset of the message column. Home passes 0 so bubbles align /// exactly with the ask bar's edges; other surfaces keep the default gutter. var horizontalContentPadding: CGFloat = ChatComposerLayout.transcriptEdgeInset - /// Explicitly enables chat-first controls only in the Chat-first shell's main - /// Chat route. Nil keeps shared transcript projections safe elsewhere. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + /// The owners a content block needs to become an interactable control. Every + /// host supplies one; there is no inert projection of the transcript. + let chatFirstRichBlockContext: ChatFirstRichBlockContext /// Optional transcript-window override for callers with a smaller initial - /// mount budget. When omitted, the existing 500-row default is preserved; - /// the existing Home-only rich-block capability selects the compact Home - /// policy automatically. + /// mount budget. When omitted, the 500-row default is preserved. Main chat + /// (`QueryAnswerThread`) passes `.compactHome` explicitly; this used to be + /// derived from "has a rich-block context", which every host now has, so the + /// derivation would have silently shrunk the task panel's window too. var transcriptWindowPolicy: ChatTranscriptWindow.Policy? = nil /// Vertical transcript inset. Home uses a tighter value because its page /// shell already provides the breathing room beneath the floating top bar. @@ -395,6 +486,9 @@ struct ChatMessagesView<WelcomeContent: View>: View { /// See `admitDailySummaryIfFollowing` (INV-CHAT-2). @State private var dailySummaryAdmitted = false @ObservedObject private var dailySummaryStore: HomeDailySummaryStore = ChatDailySummaryCoordinator.shared.store + /// Withdraws the card when the reader clears Chat. See `noteChatCleared`. + @ObservedObject private var dailySummaryCoordinator: ChatDailySummaryCoordinator = + ChatDailySummaryCoordinator.shared /// Throttle token for scrollToBottom — prevents the streaming + scroll /// detection feedback loop from saturating the main thread. @State private var scrollThrottleWorkItem: DispatchWorkItem? @@ -406,6 +500,13 @@ struct ChatMessagesView<WelcomeContent: View>: View { /// Set immediately by the scroll wheel monitor to win the race against /// throttled programmatic scrolls during streaming. @State private var userIsScrolling = false + /// The follow glide's clock. Lives across body evaluations so a newer + /// follow retargets the glide in flight instead of starting a second one. + @State private var followGlide = ChatFollowGlide() + /// When this transcript last moved its own viewport. The scroll detector + /// reads it so a follow-scroll landing under an open mouse press is not + /// mistaken for the reader taking the viewport. + @State private var programmaticScroll = ChatProgrammaticScrollSignal() /// Tracks work items for delayed initial bottom scrolls so they can be /// canceled on user scroll or disappear. @State private var initialScrollWorkItems: [DispatchWorkItem] = [] @@ -440,6 +541,11 @@ struct ChatMessagesView<WelcomeContent: View>: View { /// deliberately does not observe the object; only the overlay subscribes, so /// scrolling does not re-evaluate every message row. @State private var transcriptGeometry = ChatTranscriptGeometry() + /// The viewport as the rows see it, for sizing their collapse budgets in + /// screens. Republished only when it moves by a coarse step: this is state on + /// the view, so every change re-evaluates the transcript, and a live resize + /// drag would otherwise do that on each frame. + @State private var rowViewport: CGSize = .zero // MARK: - Activity Below Indicator @@ -487,7 +593,13 @@ struct ChatMessagesView<WelcomeContent: View>: View { $0.size } action: { size in transcriptGeometry.setViewport(size, columnWidth: size.width) + if abs(rowViewport.height - size.height) >= chatRowViewportStep + || abs(rowViewport.width - size.width) >= chatRowViewportStep + { + rowViewport = size + } } + .environment(\.chatTranscriptViewport, rowViewport) } } @@ -503,8 +615,7 @@ struct ChatMessagesView<WelcomeContent: View>: View { } private var effectiveTranscriptWindowPolicy: ChatTranscriptWindow.Policy { - transcriptWindowPolicy - ?? (chatFirstRichBlockContext == nil ? .standard : .compactHome) + transcriptWindowPolicy ?? .standard } /// A direct timeline choice leaves live-follow mode and places the selected @@ -514,6 +625,7 @@ struct ChatMessagesView<WelcomeContent: View>: View { userIsScrolling = false scrollMode = .freeScrolling hasActivityBelow = false + programmaticScroll.markProgrammaticScroll() OmiMotion.withGated(ChatPromptTimelineMetrics.jumpAnimation) { proxy.scrollTo(markID, anchor: .top) } @@ -567,8 +679,9 @@ struct ChatMessagesView<WelcomeContent: View>: View { VStack(spacing: OmiSpacing.lg) { loadMoreButton // Chrome, above the thread — not a message. It renders once, at the top, whether or not - // the transcript has rows, and it records no turn (INV-CHAT-1). - if showsDailySummary, dailySummaryAdmitted { + // the transcript has rows, and it records no turn (INV-CHAT-1). Clearing Chat withdraws + // it here rather than through admission, so it leaves with the thread on the same frame. + if showsDailySummary, dailySummaryAdmitted, !dailySummaryCoordinator.isClearedFromTranscript { ChatDailySummaryCard() } messageContent @@ -624,8 +737,13 @@ struct ChatMessagesView<WelcomeContent: View>: View { // A journal restore may be populated by background events while the // loader is still collecting its canonical snapshot. Reveal it only after // loading completes, then make one initial placement at the live edge. + // The summary admission runs first: it was deferred for the whole load + // (INV-CHAT-2), and admitting before the restore measures geometry lands + // the reader at the live edge in one pass with the card above the fold. .onChange(of: isLoadingInitial) { wasLoading, isLoading in - guard wasLoading, !isLoading, !messages.isEmpty else { return } + guard wasLoading, !isLoading else { return } + admitDailySummaryIfFollowing(proxy: proxy) + guard !messages.isEmpty else { return } handleInitialRestore(proxy: proxy) } // MARK: - Daily summary admission (INV-CHAT-2) @@ -633,6 +751,7 @@ struct ChatMessagesView<WelcomeContent: View>: View { // inserted above the viewport shifts everything below it, so it is admitted // only while the transcript follows the live edge (then re-followed), and a // reader who has scrolled away meets it on their next return to the bottom. + // The initial load defers admission entirely — see `ChatDailySummaryAdmission`. .modifier( DailySummaryAdmissionObserver( summaryID: dailySummaryStore.latest?.id, scrollMode: scrollMode, @@ -776,19 +895,28 @@ struct ChatMessagesView<WelcomeContent: View>: View { } /// Admit the daily summary card above the thread only when doing so cannot - /// move the reader: the transcript is following the live edge (so the - /// re-follow below lands it back at the bottom) or is empty. Once admitted it - /// stays; a summary that disappears (owner change) withdraws it. + /// move the reader: the initial snapshot has landed and the transcript is + /// following the live edge (so the re-follow below lands it back at the + /// bottom), or the thread is empty. Once admitted it stays; a summary that + /// disappears (owner change) withdraws it. private func admitDailySummaryIfFollowing(proxy: ScrollViewProxy) { guard showsDailySummary else { return } - guard dailySummaryStore.latest != nil else { - dailySummaryAdmitted = false + let hasAdmittableSummary = + dailySummaryStore.latest != nil && !dailySummaryCoordinator.isClearedFromTranscript + guard + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: hasAdmittableSummary, + isClearedFromTranscript: dailySummaryCoordinator.isClearedFromTranscript, + alreadyAdmitted: dailySummaryAdmitted, + isLoadingInitial: isLoadingInitial, + scrollMode: scrollMode, + hasMessages: !messages.isEmpty) + else { + if !hasAdmittableSummary { dailySummaryAdmitted = false } return } - guard !dailySummaryAdmitted else { return } - guard scrollMode == .followingBottom || messages.isEmpty else { return } dailySummaryAdmitted = true - guard !messages.isEmpty, !isLoadingInitial else { return } + guard !messages.isEmpty else { return } handleLiveContentChange(proxy: proxy) } @@ -883,6 +1011,9 @@ struct ChatMessagesView<WelcomeContent: View>: View { for (index, delay) in delays.enumerated() { let isLast = index == delays.index(before: delays.endIndex) let work = DispatchWorkItem { [self] in + // Both branches below move the viewport, so claim the movement before + // either runs rather than after. + programmaticScroll.markProgrammaticScroll() if !once.applied, let snapshot, let scrollView = transcriptGeometry.scrollView, @@ -929,8 +1060,10 @@ struct ChatMessagesView<WelcomeContent: View>: View { } } - /// Cancels all pending scheduled scrolls (throttle and initial placement). + /// Cancels all pending scheduled scrolls (throttle, initial placement, and + /// any follow glide in flight). private func cancelAllPendingScrolls() { + followGlide.cancel() scrollThrottleWorkItem?.cancel() scrollThrottleWorkItem = nil // The queued run is gone, so the throttle must stop reporting one as @@ -1098,7 +1231,7 @@ struct ChatMessagesView<WelcomeContent: View>: View { } onScrollViewResolved: { scrollView in transcriptGeometry.scrollView = scrollView } - UserScrollDetector { + UserScrollDetector(programmaticScroll: programmaticScroll) { scrollMode = .freeScrolling userIsScrolling = true hasActivityBelow = false @@ -1175,15 +1308,45 @@ struct ChatMessagesView<WelcomeContent: View>: View { } } - private func scrollToBottom(proxy: ScrollViewProxy) { + /// - Parameter animated: glide to the live edge rather than jump. The follow + /// scroll during a stream is the animated case: each new line used to snap + /// the viewport down a row at a time, and the snaps were most of what made + /// streaming feel chunky. Restores and sends stay instant — a reader + /// opening a transcript should not watch it scroll through history. + private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = false) { guard scrollMode == .followingBottom else { return } // Don't fight the user — skip if they're actively wheel/trackpad scrolling guard !userIsScrolling else { return } guard !messages.isEmpty else { return } transcriptGeometry.setFollowingLiveEdge(true) + programmaticScroll.markProgrammaticScroll() + // The glide runs on its own run-loop clock (`ChatFollowGlide`) when the + // resolved scroll view can carry it, and falls back to the snap otherwise + // — early frames before the detector resolves, and Reduce Motion, which + // gates the animation away. + if animated, OmiMotion.gated(ChatScrollFollowThrottle.followAnimation) != nil, + glideToLiveEdge() + { + return + } proxy.scrollTo("bottom-anchor", anchor: .bottom) } + /// The live edge, as a clip-view bounds target for the glide. False when no + /// scroll view has been resolved yet. + private func glideToLiveEdge() -> Bool { + guard let scrollView = transcriptGeometry.scrollView, let document = scrollView.documentView + else { return false } + let clipView = scrollView.contentView + let viewportHeight = clipView.bounds.height + let top = max(document.frame.height - viewportHeight, 0) + let originY = document.isFlipped ? top : document.frame.height - top - viewportHeight + return followGlide.glide( + clipView: clipView, + to: NSPoint(x: clipView.bounds.origin.x, y: originY), + duration: ChatScrollFollowThrottle.followDuration) + } + /// Rate-limited version of scrollToBottom: at most one follow per /// `ChatScrollFollowThrottle.interval`, and **at least** one per window for as /// long as content keeps arriving. Cancelling and rescheduling on every change @@ -1199,13 +1362,13 @@ struct ChatMessagesView<WelcomeContent: View>: View { return case .now: lastFollowScrollTime = now - scrollToBottom(proxy: proxy) + scrollToBottom(proxy: proxy, animated: true) case .schedule(let delay): hasQueuedFollowScroll = true let workItem = DispatchWorkItem { [self] in hasQueuedFollowScroll = false lastFollowScrollTime = ProcessInfo.processInfo.systemUptime - scrollToBottom(proxy: proxy) + scrollToBottom(proxy: proxy, animated: true) } scrollThrottleWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatProseRenderCache.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatProseRenderCache.swift new file mode 100644 index 00000000000..005f010abd3 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatProseRenderCache.swift @@ -0,0 +1,104 @@ +import AppKit + +/// A bounded, session-wide memo for chat prose text and its measured height. +/// +/// Chat is torn down and mounted again on every route change +/// (`ChatFirstShell` keys the destination by route), and every mount rebuilds +/// the transcript window: one Foundation Markdown parse plus one +/// `NSAttributedString` build per prose block in +/// `ChatSelectableProse.attributedString`, and one throwaway TextKit stack per +/// `sizeThatFits` query — twelve per block across the mount layout passes, +/// every one at the width the first measured. +/// +/// Measured by instrumenting both calls on the mounted transcript +/// (`ChatTranscriptGestureHarnessTests.Harness`, 120-message journal, compact +/// 50-row window, debug build): before the cache, every mount — including +/// every return to Chat — paid 50 parses and 600 height measures, 58–70 ms of +/// TextKit layout and parsing combined. With the cache, the cold mount pays +/// it once (50 and 50) and every remount pays none of it. +/// +/// The cache is keyed on the exact render inputs, so a hit is identical to a +/// recompute. Entries are immutable `NSAttributedString`s shared read-only +/// with `NSTextStorage`, which copies on edit — these text views are +/// non-editable. Bounded LRU: a streaming row mints a new key per flush, so +/// the cache turns over during an answer and the cap keeps the worst case +/// small. +@MainActor +enum ChatProseRenderCache { + struct Key: Hashable { + let markdown: String + let style: OmiMarkdown.Style + let fontSize: Int + /// Thousandths, so a fractional `fontScale` keys exactly. + let fontScaleMilli: Int + /// Sorted ordinals named by the block, or empty. + let citationOrdinals: [Int] + } + + final class Entry { + let attributed: NSAttributedString + var heightsByWidth: [CGFloat: CGFloat] = [:] + + init(attributed: NSAttributedString) { + self.attributed = attributed + } + } + + private static var entries: [Key: Entry] = [:] + private static var lruOrder: [Key] = [] + /// A full transcript window plus headroom for the streaming row's turnover. + private static let maximumEntries = 192 + /// A window resize proposes a new width per block per frame of the drag. + /// Old widths are dead once the window settles at its new size, so the map + /// is dropped wholesale at the bound rather than curated. + private static let maximumMeasuredWidthsPerEntry = 8 + + /// The entry for `key`, running `produce` on a miss. Nil when the block + /// cannot be represented as AppKit prose (a table, a fenced block) — those + /// keep their SwiftUI renderers and are never cached. + static func entry(for key: Key, produce: () -> NSAttributedString?) -> Entry? { + if let hit = entries[key] { + touch(key) + return hit + } + guard let attributed = produce() else { return nil } + let entry = Entry(attributed: attributed) + entries[key] = entry + lruOrder.append(key) + evictIfNeeded() + return entry + } + + /// Memoized TextKit height for the width the transcript proposed. The width + /// keys exactly: a sub-point change is a different wrap. + static func height(for entry: Entry, width: CGFloat, measure: () -> CGFloat) -> CGFloat { + if let cached = entry.heightsByWidth[width] { return cached } + if entry.heightsByWidth.count >= maximumMeasuredWidthsPerEntry { + entry.heightsByWidth.removeAll() + } + let measured = measure() + entry.heightsByWidth[width] = measured + return measured + } + + /// The cache is session-wide by design, which is exactly why a suite that + /// asserts on its population needs a way to start from empty. + static func removeAll() { + entries.removeAll() + lruOrder.removeAll() + } + + /// Population, for tests that pin the eviction bound. + static var entryCount: Int { entries.count } + + private static func touch(_ key: Key) { + lruOrder.removeAll { $0 == key } + lruOrder.append(key) + } + + private static func evictIfNeeded() { + while lruOrder.count > maximumEntries { + entries[lruOrder.removeFirst()] = nil + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift index 3d7a9975da1..7b9d7dd10a4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift @@ -83,6 +83,11 @@ enum ChatScrollFollowThrottle { /// falls behind, large enough that a token burst cannot saturate the main /// thread with `scrollTo` + layout. static let interval: TimeInterval = 0.08 + /// The glide from one follow to the next. Shorter than `interval` so a + /// follow always lands before the next one starts, and never a spring: the + /// live edge has nothing to overshoot into. + static let followDuration: TimeInterval = 0.16 + static let followAnimation: Animation = .easeOut(duration: followDuration) enum Decision: Equatable { /// Run the scroll on this turn. @@ -105,6 +110,145 @@ enum ChatScrollFollowThrottle { } } +/// **The follow glide's own clock.** +/// +/// SwiftUI animation transactions and AppKit's animator proxy both advance on +/// the display cycle. A transcript mounted in a test host never reaches that +/// cycle, so when the glide rode `withAnimation`, the mounted-transcript guard +/// tests (INV-CHAT-2) watched the follow silently do nothing while the live +/// edge ran 484 pt past a 600 pt viewport — the exact abandonment this +/// surface's throttle exists to prevent, invisible to the one suite that +/// guards it. A run-loop timer is driven by the run loop itself, which the app +/// and the harness both pump, so the glide the reader feels is the glide the +/// tests measure. +/// +/// Same shape as the easing it replaces (`followDuration`, ease-out): a newer +/// follow retargets the clock in flight rather than fighting it, and any +/// reader input cancels it outright. +@MainActor +final class ChatFollowGlide { + /// 60 Hz — half of the follow throttle's own cadence, so a glide lands + /// several steps before the next follow retargets. + private static let stepInterval: TimeInterval = 1.0 / 60.0 + + private var timer: Timer? + private var isGliding = false + + /// True while a glide is moving the viewport. Reader input checks this the + /// same way it checks a pending scroll. + var isActive: Bool { isGliding } + + /// Eases `clipView` to `target` over `duration`. Returns false when there is + /// nothing to ease — including the sub-point case, where the caller's snap + /// path would be wasted work but is still correct. + @discardableResult + func glide(clipView: NSClipView, to target: NSPoint, duration: TimeInterval) -> Bool { + cancel() + let start = clipView.bounds.origin + guard abs(start.y - target.y) > 0.5 else { return false } + isGliding = true + let began = Date() + let step = Timer(timeInterval: Self.stepInterval, repeats: true) { + [weak self, weak clipView] _ in + MainActor.assumeIsolated { + guard let self, let clipView, self.isGliding else { + self?.cancel() + return + } + let progress = Date().timeIntervalSince(began) / duration + guard progress < 1 else { + self.moveTo(target, in: clipView) + self.cancel() + return + } + // Ease-out cubic: fast while the reader's eye is on the arriving + // text, settling as it reaches the live edge. + let eased = 1 - pow(1 - progress, 3) + var origin = start + origin.y = start.y + (target.y - start.y) * eased + self.moveTo(origin, in: clipView) + } + } + timer = step + RunLoop.main.add(step, forMode: .common) + return true + } + + /// Reader input and teardown call this; an in-flight glide must never fight + /// the viewport's owner. + func cancel() { + timer?.invalidate() + timer = nil + isGliding = false + } + + private func moveTo(_ origin: NSPoint, in clipView: NSClipView) { + clipView.setBoundsOrigin(origin) + if let scrollView = clipView.enclosingScrollView { + scrollView.reflectScrolledClipView(clipView) + } + } +} + +/// The moments the transcript moved its own viewport. +/// +/// `UserScrollDetector` promotes an open mouse press to reader ownership as +/// soon as the clip view moves, because a scrollbar-track click repositions the +/// viewport without ever emitting a drag. That test could not tell the app's +/// own follow-scroll apart from the reader's: while an answer streams the +/// transcript re-reaches the live edge every +/// `ChatScrollFollowThrottle.interval`, so a press still open when one of those +/// lands reads as "the reader took the viewport" and ends follow mode for the +/// rest of the answer. Now that every content block is something you can click, +/// a press inside a streaming transcript is ordinary. +/// +/// Read and written only on the main thread, like every other participant in +/// the transcript's scroll handling. +final class ChatProgrammaticScrollSignal: @unchecked Sendable { + private(set) var lastScrollAt: TimeInterval? + + /// Call immediately *before* moving the viewport, so the bounds change AppKit + /// posts afterwards falls inside the grace window. + func markProgrammaticScroll(at now: TimeInterval = ProcessInfo.processInfo.systemUptime) { + lastScrollAt = now + } +} + +/// Whether viewport movement observed during a press belongs to the reader. +enum ChatPressPromotionPolicy { + /// How late a programmatic scroll's bounds change may still arrive. AppKit + /// posts it on the same or the next main turn, so this only has to outlive a + /// runloop hop — not a gesture. + static let programmaticScrollGrace: TimeInterval = 0.2 + + enum Movement: Equatable { + /// The reader moved the viewport: the press owns it now. + case promotesPress + /// The transcript moved itself. Measure the reader's next movement from + /// where it left the viewport rather than from where the press began, + /// otherwise one follow-scroll's displacement is charged to the reader for + /// as long as the press stays open. + case rebaselines + /// Nothing moved far enough to mean anything. + case ignores + } + + static func classify( + movement: CGFloat, + epsilon: CGFloat, + now: TimeInterval, + lastProgrammaticScrollAt: TimeInterval? + ) -> Movement { + guard abs(movement) >= epsilon else { return .ignores } + guard let lastProgrammaticScrollAt else { return .promotesPress } + let elapsed = now - lastProgrammaticScrollAt + // A clock that went backwards must not hand the app an open-ended excuse to + // discount reader movement. + guard elapsed >= 0, elapsed <= programmaticScrollGrace else { return .promotesPress } + return .rebaselines + } +} + /// A stable representable host that tells its coordinator when SwiftUI moves it /// between transcript hierarchies. The enclosing NSScrollView is not guaranteed /// to survive a lazy document replacement, especially during a fast gesture. @@ -125,6 +269,9 @@ private final class ScrollDetectorHostView: NSView { /// Detects user scroll-wheel / trackpad gestures, mouse interactions, and /// keyboard scroll-navigation on the enclosing NSScrollView. struct UserScrollDetector: NSViewRepresentable { + /// The transcript's own record of when it last moved the viewport. Shared so + /// the coordinator can tell an app-driven bounds change from the reader's. + let programmaticScroll: ChatProgrammaticScrollSignal let onUserScroll: () -> Void var onUserScrollEnded: () -> Void = {} var onScrollSettledAtBottom: () -> Void = {} @@ -152,6 +299,7 @@ struct UserScrollDetector: NSViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator( onUserScroll: onUserScroll, + programmaticScroll: programmaticScroll, onUserScrollEnded: onUserScrollEnded, onScrollSettledAtBottom: onScrollSettledAtBottom ) @@ -161,6 +309,7 @@ struct UserScrollDetector: NSViewRepresentable { let onUserScroll: () -> Void let onUserScrollEnded: () -> Void let onScrollSettledAtBottom: () -> Void + private let programmaticScroll: ChatProgrammaticScrollSignal private var monitor: Any? private weak var installedScrollView: NSScrollView? private var settleWorkItem: DispatchWorkItem? @@ -191,12 +340,17 @@ struct UserScrollDetector: NSViewRepresentable { 119, // End ] + /// `programmaticScroll` defaults to a signal that has never fired — "the app + /// has not moved the viewport" — which is what a coordinator built outside + /// the transcript means. Production always passes the transcript's own. init( onUserScroll: @escaping () -> Void, + programmaticScroll: ChatProgrammaticScrollSignal = ChatProgrammaticScrollSignal(), onUserScrollEnded: @escaping () -> Void = {}, onScrollSettledAtBottom: @escaping () -> Void ) { self.onUserScroll = onUserScroll + self.programmaticScroll = programmaticScroll self.onUserScrollEnded = onUserScrollEnded self.onScrollSettledAtBottom = onScrollSettledAtBottom } @@ -259,6 +413,16 @@ struct UserScrollDetector: NSViewRepresentable { let handler: @MainActor (NSEvent) -> NSEvent? = { [weak self] event in guard let self else { return event } + // A press must never outlive its own release. Clicking inside the + // transcript can open something that presents in its own window — the + // "Select Text\u{2026}" popover, a context menu — and the release is + // then delivered there, where the same-window guard below dropped it. + // The candidate would stay open for the life of the scroll view, and + // the next follow-scroll promote it. + if event.type == .leftMouseUp { + self.endPressCandidate(on: targetScrollView) + return event + } guard event.window == targetScrollView.window else { return event } if event.type == .keyDown { @@ -289,8 +453,6 @@ struct UserScrollDetector: NSViewRepresentable { let locationInScrollView = targetScrollView.convert(event.locationInWindow, from: nil) guard targetScrollView.bounds.contains(locationInScrollView) else { break } self.beginPressCandidate(on: targetScrollView) - case .leftMouseUp: - self.endPressCandidate(on: targetScrollView) default: // Deliberately not bounds-checked: a scrollbar drag and a // selection autoscroll both leave the transcript's bounds while @@ -360,6 +522,12 @@ struct UserScrollDetector: NSViewRepresentable { @MainActor private func beginPressCandidate(on scrollView: NSScrollView) { + // A press whose release never reached this monitor must not leave its + // observer registered behind the new one. + if let observation = pressBoundsObservation { + NotificationCenter.default.removeObserver(observation) + pressBoundsObservation = nil + } pressOriginScrollTop = Self.scrollTop(of: scrollView) pressCandidateOwnsViewport = false // A scrollbar track click repositions the viewport during mouse-down and @@ -379,9 +547,21 @@ struct UserScrollDetector: NSViewRepresentable { @MainActor private func promotePressCandidateIfMoved(on scrollView: NSScrollView) { guard let origin = pressOriginScrollTop, !pressCandidateOwnsViewport else { return } - guard abs(Self.scrollTop(of: scrollView) - origin) >= Self.dragMovementEpsilon else { return } - pressCandidateOwnsViewport = true - onUserScroll() + let current = Self.scrollTop(of: scrollView) + switch ChatPressPromotionPolicy.classify( + movement: current - origin, + epsilon: Self.dragMovementEpsilon, + now: ProcessInfo.processInfo.systemUptime, + lastProgrammaticScrollAt: programmaticScroll.lastScrollAt + ) { + case .ignores: + return + case .rebaselines: + pressOriginScrollTop = current + case .promotesPress: + pressCandidateOwnsViewport = true + onUserScroll() + } } @MainActor @@ -618,6 +798,9 @@ struct ChatScrollContainer<Content: View>: View { @State private var lastViewportSize: CGSize = .zero @State private var lastFollowScrollTime: TimeInterval? @State private var hasQueuedFollowScroll = false + /// Same contract as `ChatMessagesView`: a follow-scroll landing under an open + /// press is the app moving the viewport, not the reader taking it. + @State private var programmaticScroll = ChatProgrammaticScrollSignal() var body: some View { ScrollViewReader { proxy in @@ -659,7 +842,7 @@ struct ChatScrollContainer<Content: View>: View { } private var scrollDetectors: some View { - UserScrollDetector { + UserScrollDetector(programmaticScroll: programmaticScroll) { scrollMode = .freeScrolling userIsScrolling = true hasActivityBelow = false @@ -760,6 +943,7 @@ struct ChatScrollContainer<Content: View>: View { private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { guard scrollMode == .followingBottom, !userIsScrolling else { return } + programmaticScroll.markProgrammaticScroll() if animated { OmiMotion.withGated(.easeOut(duration: 0.15)) { proxy.scrollTo(bottomAnchorId, anchor: .bottom) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift new file mode 100644 index 00000000000..b1534ad2dd7 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift @@ -0,0 +1,462 @@ +import AppKit +import OmiTheme +import SwiftUI + +/// **Selection where the words already are.** +/// +/// The transcript used to answer "let me copy that date out of your answer" +/// with a popover: `ChatSelectableTextPopover` re-printed the message, in raw +/// Markdown, in a floating box beside the row the reader was already looking +/// at. It was the only remedy available, because SwiftUI's own selection is +/// permanently barred here — PR #10834 put SwiftUI's own native selection back +/// on settled rows and reopened FC-selection-overlay-layout-loop in Omi Beta +/// 0.12.146, with every sampled main-thread stack in `SelectionOverlay`, +/// `setFont` and AttributeGraph while memory climbed without bound. +/// +/// That boundary is about `SelectionOverlay`, not about selection. An +/// `NSTextView` *is* its own selection: one view owns one selection, a parent +/// rebuild replaces a string instead of installing a second overlay, and +/// nothing per-`Text` is mounted at all. `ChatSelectableTextPopover` already +/// said so in its own header — it just kept that view outside the transcript. +/// This brings it inside, so a reader drags across the answer in place, on +/// their own turns as much as Omi's, and `⌘C` copies exactly what they +/// highlighted. +/// +/// `.github/scripts/check_chat_selection_boundary.py` still forbids SwiftUI +/// selection in the live transcript, and now also forbids it here. +enum ChatSelectableProse { + /// The scheme the transcript's own citation markers travel under. It is not + /// openable by the system: the click is handled in-process and the URL never + /// reaches `NSWorkspace`. + static let citationScheme = "omi-citation" + + static func citationOrdinal(from url: URL) -> Int? { + guard url.scheme == citationScheme else { return nil } + return Int(url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + } + + /// The one place chat prose becomes AppKit text. + /// + /// It parses exactly what `OmiMarkdownContent.styledAttributedString` parses — + /// same preprocessing, same tilde rule, same inline-only syntax — and then + /// maps the parsed *intents* rather than SwiftUI's own attributes, which do + /// not bridge. Anything this cannot represent (a table, a fenced block) never + /// reaches here; those keep their SwiftUI renderers and their copy controls. + static func attributedString( + markdown source: String, + style: OmiMarkdown.Style, + fontSize: CGFloat, + fontScale: CGFloat, + citationOrdinals: Set<Int> = [] + ) -> NSAttributedString? { + let processed = OmiMarkdownContent.preprocessText(source) + let escaped = OmiMarkdownTilde.escapingNonPairDelimiters(processed) + guard + let parsed = try? AttributedString( + markdown: escaped, + options: .init( + allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + ) + else { return nil } + + let codeFontSize = round(13 * fontScale) + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = OmiMarkdownContent.chatLineSpacing(fontSize: fontSize) + let result = NSMutableAttributedString() + + for run in parsed.runs { + let text = String(parsed[run.range].characters) + guard !text.isEmpty else { continue } + let intent = run.inlinePresentationIntent ?? [] + let isCode = intent.contains(.code) + var attributes: [NSAttributedString.Key: Any] = [ + .font: font( + size: isCode ? codeFontSize : fontSize, + bold: intent.contains(.stronglyEmphasized), + italic: intent.contains(.emphasized), + code: isCode + ), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph, + ] + if isCode { + // The same chip wash the SwiftUI renderer paints, flattened: a run + // background cannot round its corners, and a rounded corner is not + // worth an attachment that would drop out of the copied text. + attributes[.backgroundColor] = NSColor.labelColor.withAlphaComponent(0.085) + } + if let link = run.link { + attributes[.link] = link + attributes[.foregroundColor] = NSColor.systemBlue + if style == .user { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } + } + result.append(NSAttributedString(string: text, attributes: attributes)) + } + + applyCitationLinks(to: result, ordinals: citationOrdinals) + return result + } + + /// `[7]` is a marker the transcript owns, not Markdown. Markdown leaves it as + /// literal text (there is no link destination after it), so it is still here + /// to find, and turning it into a link keeps it clickable *and* selectable — + /// the chip button it replaces was neither. + /// + /// The pattern is the transcript's own, not a second copy of it: ordinals run + /// to four digits and the model also writes kind-prefixed markers like + /// `[memory 5023]`, both of which a hand-rolled `\[\d{1,3}\]` quietly left + /// as dead text. + static func applyCitationLinks(to text: NSMutableAttributedString, ordinals: Set<Int>) { + guard !ordinals.isEmpty else { return } + guard + let pattern = try? NSRegularExpression(pattern: ChatCitationMarkup.numericMarkerPattern) + else { return } + let full = NSRange(location: 0, length: text.length) + for match in pattern.matches(in: text.string, range: full).reversed() { + guard match.numberOfRanges == 2, + let digits = Range(match.range(at: 1), in: text.string), + let ordinal = Int(text.string[digits]), + ordinals.contains(ordinal), + let url = URL(string: "\(citationScheme)://\(ordinal)") + else { continue } + text.addAttributes( + [.link: url, .foregroundColor: NSColor.systemBlue], range: match.range) + } + } + + private static func font(size: CGFloat, bold: Bool, italic: Bool, code: Bool) -> NSFont { + if code { return .monospacedSystemFont(ofSize: size, weight: bold ? .semibold : .regular) } + let base = bold ? NSFont.boldSystemFont(ofSize: size) : NSFont.systemFont(ofSize: size) + guard italic else { return base } + let italicized = NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) + return italicized + } +} + +/// One `NSTextView`, laid out by SwiftUI, drawing one run of chat prose. +/// +/// Deliberately *not* `NSTextView.scrollableTextView()`: an inner scroller +/// would swallow the transcript's own trackpad gestures the way a fenced code +/// block does. This view has no scroller, reports the height its text needs at +/// the proposed width, and lets the transcript do the scrolling. +struct ChatSelectableProseText: NSViewRepresentable { + let attributed: NSAttributedString + /// The shared-cache entry this block's parse lives in, when it came from + /// `ChatSelectableProseBlock`. Lets the measured height ride the same entry + /// instead of a throwaway TextKit stack per `sizeThatFits` query — SwiftUI + /// issues about nine of those per block across the mount layout passes. + /// Standalone constructions leave it nil and measure every time. + var heightEntry: ChatProseRenderCache.Entry? + var onOpenCitation: ((Int) -> Void)? + /// Reports the citation under the pointer and the rectangle its marker + /// occupies, so the transcript can anchor the same source preview the chip + /// used to open. `nil` means the pointer left every marker. + var onHoverCitation: ((CitationHover?) -> Void)? + + struct CitationHover: Equatable { + let ordinal: Int + let rect: CGRect + } + + func makeCoordinator() -> Coordinator { + Coordinator(onOpenCitation: onOpenCitation, onHoverCitation: onHoverCitation) + } + + func makeNSView(context: Context) -> NSTextView { + let textView = ChatProseTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.isRichText = false + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.isVerticallyResizable = false + textView.isHorizontallyResizable = false + textView.linkTextAttributes = [ + .foregroundColor: NSColor.systemBlue, + .cursor: NSCursor.pointingHand, + ] + textView.delegate = context.coordinator + textView.onHoverCitation = { [weak coordinator = context.coordinator] hover in + coordinator?.onHoverCitation?(hover) + } + textView.textStorage?.setAttributedString(attributed) + return textView + } + + func updateNSView(_ textView: NSTextView, context: Context) { + context.coordinator.onOpenCitation = onOpenCitation + context.coordinator.onHoverCitation = onHoverCitation + guard textView.textStorage?.isEqual(to: attributed) != true else { return } + // Replacing the storage of the one view that owns this selection. There is + // no second overlay to install, which is why this is AppKit. + textView.textStorage?.setAttributedString(attributed) + } + + /// Height for the width the transcript proposed, measured **beside** the + /// live view rather than inside it. + /// + /// Measuring in the view's own text container is what broke the column: the + /// container was left holding a measurement width, the frame later arrived at + /// a different one, and the answer wrapped to neither. A throwaway layout + /// manager answers the question without touching what is on screen, and the + /// live container simply tracks the frame it is finally given. + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? { + guard let width = proposal.width, width > 0, width < .greatestFiniteMagnitude else { return nil } + let height: CGFloat + if let heightEntry { + height = ChatProseRenderCache.height(for: heightEntry, width: width) { + Self.height(of: attributed, fittingWidth: width) + } + } else { + height = Self.height(of: attributed, fittingWidth: width) + } + return CGSize(width: width, height: height) + } + + /// Exposed so a test can assert the row's height without mounting a window. + static func height(of attributed: NSAttributedString, fittingWidth width: CGFloat) -> CGFloat { + let storage = NSTextStorage(attributedString: attributed) + let container = NSTextContainer(size: NSSize(width: width, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + let layoutManager = NSLayoutManager() + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: container) + return ceil(layoutManager.usedRect(for: container).height) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var onOpenCitation: ((Int) -> Void)? + var onHoverCitation: ((CitationHover?) -> Void)? + + init( + onOpenCitation: ((Int) -> Void)?, + onHoverCitation: ((CitationHover?) -> Void)? + ) { + self.onOpenCitation = onOpenCitation + self.onHoverCitation = onHoverCitation + } + + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + guard let url = link as? URL ?? (link as? String).flatMap(URL.init(string:)) else { return false } + if let ordinal = ChatSelectableProse.citationOrdinal(from: url) { + onOpenCitation?(ordinal) + return true + } + // Everything else is an ordinary Markdown link and belongs to the browser. + return false + } + } +} + +/// A text view that reads as prose rather than as a control. +/// +/// Two AppKit defaults are wrong for a transcript: the field editor's I-beam +/// tracking rectangle is fine, but the view would otherwise accept first +/// responder from a `Tab` walk and steal the composer's focus ring, and a +/// right-click would open AppKit's editing menu instead of the row's own +/// "Copy Message" menu. +final class ChatProseTextView: NSTextView { + var onHoverCitation: ((ChatSelectableProseText.CitationHover?) -> Void)? + private var hoveredOrdinal: Int? + + override var acceptsFirstResponder: Bool { true } + + /// The column belongs to the transcript. Claiming an intrinsic width here is + /// what let a short line pull the whole row in from the container edge. + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.filter { $0.owner === self }.forEach(removeTrackingArea) + addTrackingArea( + NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self)) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + publishHover(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + publishHover(at: nil) + } + + /// Point to marker. A miss is as meaningful as a hit — it is what dismisses + /// a preview the reader has moved away from. + private func publishHover(at point: CGPoint?) { + guard let point, let layoutManager, let textContainer else { + publish(nil) + return + } + let glyph = layoutManager.glyphIndex(for: point, in: textContainer) + let bounds = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyph, length: 1), in: textContainer) + guard bounds.contains(point) else { + publish(nil) + return + } + let index = layoutManager.characterIndexForGlyph(at: glyph) + guard index < (textStorage?.length ?? 0) else { + publish(nil) + return + } + var range = NSRange(location: 0, length: 0) + guard let url = textStorage?.attribute(.link, at: index, effectiveRange: &range) as? URL, + let ordinal = ChatSelectableProse.citationOrdinal(from: url) + else { + publish(nil) + return + } + let rect = layoutManager.boundingRect(forGlyphRange: range, in: textContainer) + guard hoveredOrdinal != ordinal else { return } + hoveredOrdinal = ordinal + onHoverCitation?(.init(ordinal: ordinal, rect: rect)) + } + + private func publish(_ hover: ChatSelectableProseText.CitationHover?) { + guard hoveredOrdinal != nil else { return } + hoveredOrdinal = nil + onHoverCitation?(hover) + } + + /// Focus arrives by clicking into the words, never by tabbing through them. + override func becomeFirstResponder() -> Bool { + guard NSApp.currentEvent?.type != .keyDown else { return false } + return super.becomeFirstResponder() + } + + override func menu(for event: NSEvent) -> NSMenu? { + // Nothing selected — let the row's context menu answer, so "Copy Message" + // stays one right-click away from anywhere in the bubble. + guard selectedRange().length > 0 else { return nil } + return super.menu(for: event) + } +} + +/// One run of chat prose, selectable, with the citation preview the chip used +/// to own. +/// +/// The chip was a `Button` inside a flow layout, which is precisely what made +/// the surrounding words unselectable: a line of prose had to be chopped into +/// per-segment `Text` views to make room for it. Here the marker is a link +/// range inside the one text view, so the same `[7]` is draggable, copyable, +/// clickable *and* still opens its source on hover. +struct ChatSelectableProseBlock: View { + let text: String + let style: OmiMarkdown.Style + let fontScale: CGFloat + let citations: [ChatCitationReference] + let onOpenCitation: ((ChatCitationReference) -> Void)? + + @State private var hover: ChatSelectableProseText.CitationHover? + @State private var isPreviewHovering = false + @State private var hoverGeneration = 0 + + private var referencesByOrdinal: [Int: ChatCitationReference] { + Dictionary(citations.map { ($0.ordinal, $0) }, uniquingKeysWith: { first, _ in first }) + } + + private var hoveredReference: ChatCitationReference? { + hover.flatMap { referencesByOrdinal[$0.ordinal] } + } + + var body: some View { + let fontSize = round(14 * fontScale) + // The parse and the measured height are keyed on the exact render inputs, + // so a hit is identical to a recompute — and a route return, which rebuilds + // this block from the same transcript text, hits instead of paying the + // Foundation Markdown parse and the throwaway TextKit layout again. See + // `ChatProseRenderCache` for the measured cost this retires. + let cacheKey = ChatProseRenderCache.Key( + markdown: text, + style: style, + fontSize: Int(fontSize), + fontScaleMilli: Int((fontScale * 1_000).rounded()), + citationOrdinals: citations.map(\.ordinal).sorted()) + if let entry = ChatProseRenderCache.entry( + for: cacheKey, + produce: { + ChatSelectableProse.attributedString( + markdown: text, + style: style, + fontSize: fontSize, + fontScale: fontScale, + citationOrdinals: Set(citations.map(\.ordinal))) + }) + { + ChatSelectableProseText( + attributed: entry.attributed, + heightEntry: entry, + onOpenCitation: { ordinal in + guard let reference = referencesByOrdinal[ordinal], reference.canOpen else { return } + hover = nil + onOpenCitation?(reference) + }, + onHoverCitation: { value in + hoverGeneration += 1 + let generation = hoverGeneration + if value == nil { + schedulePreviewDismiss(generation: generation) + } else { + hover = value + } + } + ) + // Without this the row asks the text for its ideal width and gets the + // text's own, not the column's: assistant prose stopped 200pt short of + // the container edge and re-wrapped inside a gutter nobody reserved. + .frame(maxWidth: .infinity, alignment: .leading) + .popover( + // A real binding, not a constant: SwiftUI writes `false` back when the + // reader dismisses the preview, and a constant would swallow that and + // leave a popover that cannot be closed. + isPresented: Binding( + get: { hoveredReference != nil }, + set: { if !$0 { hover = nil } } + ), + attachmentAnchor: .rect(.rect(hover?.rect ?? .zero)), + arrowEdge: .bottom + ) { + if let reference = hoveredReference { + ChatCitationPreview( + reference: reference, + fontScale: fontScale, + onOpen: { + hover = nil + onOpenCitation?(reference) + } + ) + .onHover { hovering in + isPreviewHovering = hovering + hoverGeneration += 1 + if !hovering { schedulePreviewDismiss(generation: hoverGeneration) } + } + } + } + } else { + // The parse failed, which is not a reason to withhold the words. + OmiMarkdownChatText(text, fontSize: fontSize, style: style) + } + } + + /// The pointer crosses the gap between marker and popover; dismissing on the + /// first miss would make the preview unreachable. + private func schedulePreviewDismiss(generation: Int) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + guard !isPreviewHovering, generation == hoverGeneration else { return } + hover = nil + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift deleted file mode 100644 index 6b3e42f2a91..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift +++ /dev/null @@ -1,190 +0,0 @@ -import OmiTheme -import SwiftUI - -struct ScoreWidget: View { - let scoreResponse: ScoreResponse? - - private var weeklyScore: ScoreData { - scoreResponse?.weekly ?? ScoreData(score: 0, completedTasks: 0, totalTasks: 0) - } - - private var scoreColor: Color { - if !weeklyScore.hasTasks { - return Ink.secondary - } - let score = weeklyScore.score - if score >= 80 { - return .green - } else if score >= 60 { - return Color(red: 0.8, green: 0.8, blue: 0.0) - } else if score >= 40 { - return .orange - } else { - return .red - } - } - - var body: some View { - GeometryReader { geometry in - let gaugeWidth = min(geometry.size.width * 0.55, 180) - let gaugeHeight = gaugeWidth / 2 - let lineWidth = max(gaugeWidth * 0.085, 8) - let fontSize = max(gaugeWidth * 0.2, 18) - - VStack(spacing: OmiSpacing.lg) { - // Semicircle gauge - ZStack { - // Background arc - SemicircleShape() - .stroke(Ink.rowFillHover, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .frame(width: gaugeWidth, height: gaugeHeight) - - // Progress arc - SemicircleShape() - .trim(from: 0, to: min(weeklyScore.score / 100, 1.0)) - .stroke(scoreColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .frame(width: gaugeWidth, height: gaugeHeight) - .omiAnimation(.easeInOut(duration: 0.3), value: weeklyScore.score) - - // Score text - VStack(spacing: OmiSpacing.hairline) { - Text("\(Int(weeklyScore.score))%") - .scaledFont(size: fontSize, weight: .bold) - .foregroundColor(Ink.primary) - .contentTransition(.numericText()) - } - .offset(y: gaugeHeight * 0.14) - } - - // Task count and subtitle - VStack(spacing: OmiSpacing.xxs) { - if weeklyScore.hasTasks { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "checkmark.circle.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(scoreColor) - Text("\(weeklyScore.completedTasks) of \(weeklyScore.totalTasks) tasks completed") - .scaledMonospacedDigitFont(size: 12) - .foregroundColor(Ink.secondary) - .contentTransition(.numericText()) - } - } else { - Text("No tasks this week") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - - Text("Last 7 days") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(OmiSpacing.xl) - } - .frame(minHeight: 200) - .glassCard() - } -} - -// MARK: - Legacy Widget (for backwards compatibility) - -struct DailyScoreWidget: View { - let dailyScore: DailyScore? - - private var score: Double { - dailyScore?.score ?? 0 - } - - private var hasTasksToday: Bool { - (dailyScore?.totalTasks ?? 0) > 0 - } - - private var scoreColor: Color { - // Grey when no tasks (like Flutter) - if !hasTasksToday { - return Ink.secondary - } - if score >= 80 { - return .green - } else if score >= 60 { - return Color(red: 0.8, green: 0.8, blue: 0.0) // Lime/Yellow - } else if score >= 40 { - return .orange - } else { - return .red - } - } - - var body: some View { - VStack(spacing: OmiSpacing.lg) { - // Header - HStack { - Text("Daily Score") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - Spacer() - } - - // Semicircle gauge - ZStack { - // Background arc - SemicircleShape() - .stroke(Ink.rowFillHover, style: StrokeStyle(lineWidth: 12, lineCap: .round)) - .frame(width: 140, height: 70) - - // Progress arc - SemicircleShape() - .trim(from: 0, to: min(score / 100, 1.0)) - .stroke(scoreColor, style: StrokeStyle(lineWidth: 12, lineCap: .round)) - .frame(width: 140, height: 70) - - // Score text - VStack(spacing: OmiSpacing.hairline) { - Text("\(Int(score))%") - .scaledFont(size: OmiType.title, weight: .bold) - .foregroundColor(Ink.primary) - } - .offset(y: 10) - } - - // Task count - if let ds = dailyScore, ds.totalTasks > 0 { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "checkmark.circle.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(scoreColor) - Text("\(ds.completedTasks) of \(ds.totalTasks) tasks completed") - .scaledMonospacedDigitFont(size: 12) - .foregroundColor(Ink.secondary) - } - } else { - Text("No tasks due today") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - } - .padding(OmiSpacing.xl) - .glassCard() - } -} - -// MARK: - Semicircle Shape - -struct SemicircleShape: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - let center = CGPoint(x: rect.midX, y: rect.maxY) - let radius = min(rect.width, rect.height * 2) / 2 - - path.addArc( - center: center, - radius: radius, - startAngle: .degrees(180), - endAngle: .degrees(0), - clockwise: false - ) - - return path - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift deleted file mode 100644 index 6fc8600309f..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift +++ /dev/null @@ -1,965 +0,0 @@ -import OmiTheme -import SwiftUI - -// MARK: - Goals Widget - -struct GoalsWidget: View { - let goals: [Goal] - let onCreateGoal: (String, Double, Double) -> Void // (title, currentValue, targetValue) - let onUpdateGoal: (Goal, String, Double, Double) -> Void - let onUpdateProgress: (Goal, Double) -> Void - let onDeleteGoal: (Goal) -> Void - - @State private var editingGoal: Goal? = nil - @State private var showingCreateSheet = false - @State private var showingHistory = false - @State private var isGeneratingGoal = false - - // AI Features - @State private var selectedGoalForInsight: Goal? = nil - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.lg) { - // Header - HStack { - Text("Goals") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Spacer() - - // Add goal button (only if less than 3 goals) - if goals.count < 4 { - GoalHeaderButton(icon: "plus", tooltip: "Add goal", color: Ink.secondary) { - showingCreateSheet = true - } - } - } - - if goals.isEmpty { - // Empty state — header already has a + button, so just offer - // the AI generation action centered in the empty area. - VStack(spacing: 0) { - Spacer(minLength: 0) - - Button(action: { triggerGoalGeneration() }) { - HStack(spacing: OmiSpacing.xs) { - if isGeneratingGoal { - ProgressView() - .scaleEffect(0.6) - .frame(width: 12, height: 12) - } else { - Image(systemName: "sparkles") - .scaledFont(size: OmiType.caption) - } - Text(isGeneratingGoal ? "Generating..." : "Generate AI Goal") - .scaledFont(size: OmiType.body, weight: .medium) - } - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .glassChip(isActive: true) - } - .buttonStyle(.plain) - .disabled(isGeneratingGoal) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - // Goals list — centered vertically in remaining cell height - // so a shorter Goals list floats to the middle when the - // Tasks card determines the row's intrinsic height. - VStack(spacing: 0) { - Spacer(minLength: 0) - - VStack(spacing: OmiSpacing.md) { - ForEach(Array(goals.enumerated()), id: \.element.id) { index, goal in - GoalRowView( - goal: goal, - index: index, - onTap: { editingGoal = goal }, - onUpdateProgress: { value in onUpdateProgress(goal, value) }, - onDelete: { onDeleteGoal(goal) }, - onGetInsight: { - selectedGoalForInsight = goal - } - ) - } - } - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .padding(OmiSpacing.xl) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .glassCard() - .sheet(isPresented: $showingCreateSheet) { - GoalEditSheet( - goal: nil, - onSave: { title, current, target in - onCreateGoal(title, current, target) - }, - onDelete: nil, - onDismiss: { showingCreateSheet = false } - ) - } - .sheet(item: $editingGoal) { goal in - GoalEditSheet( - goal: goal, - onSave: { title, current, target in - onUpdateGoal(goal, title, current, target) - }, - onDelete: { - onDeleteGoal(goal) - }, - onDismiss: { editingGoal = nil } - ) - } - .sheet(item: $selectedGoalForInsight) { goal in - GoalInsightSheet( - goal: goal, - onDismiss: { selectedGoalForInsight = nil } - ) - } - .sheet(isPresented: $showingHistory) { - GoalsHistoryPage(onDismiss: { showingHistory = false }) - .frame(width: 480, height: 500) - } - } - - private func triggerGoalGeneration() { - isGeneratingGoal = true - Task { - await GoalGenerationService.shared.generateNow() - isGeneratingGoal = false - } - } -} - -// MARK: - Goal Row View - -struct GoalRowView: View { - let goal: Goal - let index: Int - let onTap: () -> Void - let onUpdateProgress: (Double) -> Void - let onDelete: () -> Void - var onGetInsight: (() -> Void)? = nil - - @State private var isHovering = false - @State private var isDragging = false - @State private var dragValue: Double? = nil - @State private var isExpanded = false - @State private var linkedTasks: [TaskActionItem] = [] - @State private var hasLoadedTasks = false - - /// The progress fraction (0-1) to display, using drag value when active - private var displayProgress: Double { - if let dv = dragValue { - return min(max(dv, 0), 1) - } - return min(goal.progress / 100.0, 1.0) - } - - private var progressColor: Color { - let progress = displayProgress - if progress >= 0.8 { - return Color(red: 0.133, green: 0.773, blue: 0.369) // #22C55E Green - } else if progress >= 0.6 { - return Color(red: 0.518, green: 0.8, blue: 0.086) // #84CC16 Lime - } else if progress >= 0.4 { - return Color(red: 0.984, green: 0.749, blue: 0.141) // #FBBF24 Yellow - } else if progress >= 0.2 { - return Color(red: 0.976, green: 0.451, blue: 0.086) // #F97316 Orange - } else { - return Ink.secondary - } - } - - private var dragProgressText: String { - let currentVal: Double - if let dv = dragValue { - let raw = goal.minValue + dv * (goal.targetValue - goal.minValue) - currentVal = max(goal.minValue, min(raw, goal.targetValue)) - } else { - currentVal = goal.currentValue - } - return "\(Int(currentVal.rounded()))/\(Int(goal.targetValue.rounded()))" - } - - var body: some View { - HStack(spacing: OmiSpacing.md) { - // Emoji icon - tapping opens edit sheet - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill.opacity(0.9)) - .frame(width: 36, height: 36) - Text(goalEmoji) - .scaledFont(size: OmiType.subheading) - } - .onTapGesture { onTap() } - - // Content - VStack(alignment: .leading, spacing: OmiSpacing.md) { - HStack { - Text(goal.title) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.primary) - .lineLimit(1) - .onTapGesture { onTap() } - - Spacer() - - // Expand/collapse button (if has description or linked tasks) - if goal.description != nil || !linkedTasks.isEmpty { - Button(action: { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - }) { - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .scaledFont(size: OmiType.micro, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - } - - // Advice button (shown on hover) - if isHovering, let onGetInsight = onGetInsight { - Button(action: onGetInsight) { - Image(systemName: "lightbulb.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(.yellow) - } - .buttonStyle(.plain) - .transition(.opacity) - } - - // Progress value (current/target) - Text(dragProgressText) - .scaledFont(size: OmiType.caption) - .foregroundColor(isDragging ? Ink.primary : Ink.secondary) - .omiAnimation(.easeInOut(duration: 0.15), value: isDragging) - } - - // Progress bar with drag gesture - GeometryReader { geometry in - ZStack(alignment: .leading) { - // Background track - visible light gray - RoundedRectangle(cornerRadius: OmiChrome.stripRadius) - .fill(Ink.rowFill) - .frame(height: isDragging ? 8 : 6) - - // Progress fill - RoundedRectangle(cornerRadius: OmiChrome.stripRadius) - .fill(progressColor) - .frame( - width: max(0, geometry.size.width * displayProgress), - height: isDragging ? 8 : 6 - ) - - // Drag thumb - always visible - Circle() - .fill(Ink.primary) - .frame(width: 14, height: 14) - .shadow(color: .black.opacity(0.08), radius: 2, y: 1) - .offset(x: max(0, min(geometry.size.width * displayProgress - 7, geometry.size.width - 14))) - } - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isDragging = true - let fraction = value.location.x / geometry.size.width - dragValue = min(max(fraction, 0), 1) - } - .onEnded { _ in - if let dv = dragValue { - let finalValue = goal.minValue + dv * (goal.targetValue - goal.minValue) - let clampedValue = max(goal.minValue, min(finalValue, goal.targetValue)) - let roundedValue = clampedValue.rounded() - onUpdateProgress(roundedValue) - } - isDragging = false - dragValue = nil - } - ) - } - .frame(height: 18) - .omiAnimation(.easeInOut(duration: 0.15), value: isDragging) - - // Expanded section: description + linked tasks - if isExpanded { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - // Description - if let desc = goal.description, !desc.isEmpty { - Text(desc) - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - .lineLimit(3) - } - - // Linked tasks - if !linkedTasks.isEmpty { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Linked Tasks") - .scaledFont(size: OmiType.micro, weight: .semibold) - .foregroundColor(Ink.secondary) - .textCase(.uppercase) - - ForEach(linkedTasks) { task in - HStack(spacing: OmiSpacing.xs) { - Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") - .scaledFont(size: OmiType.caption) - .foregroundColor( - task.completed ? Color(red: 0.133, green: 0.773, blue: 0.369) : Ink.secondary) - - Text(task.description) - .scaledFont(size: OmiType.caption) - .foregroundColor(task.completed ? Ink.secondary : Ink.primary) - .strikethrough(task.completed) - .lineLimit(1) - } - } - } - } - } - .padding(.top, OmiSpacing.hairline) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - .padding(.vertical, OmiSpacing.md) - .padding(.horizontal, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .fill(Ink.rowFillHover.opacity(isHovering ? 0.9 : 0.72)) - ) - .onHover { hovering in - OmiMotion.withGated(.easeInOut(duration: 0.15)) { - isHovering = hovering - } - } - .task { - guard !hasLoadedTasks else { return } - hasLoadedTasks = true - await loadLinkedTasks() - } - } - - private func loadLinkedTasks() async { - do { - let response = try await APIClient.shared.getActionItems(limit: 100, completed: nil) - linkedTasks = response.items.filter { $0.goalId == goal.id } - } catch { - // Silently fail — linked tasks are supplementary - } - } - - private var goalEmoji: String { - let title = goal.title.lowercased() - - // Money/Revenue - if title.contains("revenue") || title.contains("money") || title.contains("income") || title.contains("profit") - || title.contains("sales") || title.contains("$") || title.contains("dollar") || title.contains("earn") - { - return "💰" - } - // Growth/Users - if title.contains("users") || title.contains("customers") || title.contains("clients") - || title.contains("subscribers") || title.contains("followers") || title.contains("growth") - || title.contains("million") || title.contains("1m") || title.contains("10k") || title.contains("100k") - || title.contains("mrr") || title.contains("arr") - { - return "🚀" - } - // Startup/Business - if title.contains("startup") || title.contains("launch") || title.contains("business") || title.contains("company") - { - return "🏆" - } - // Investment - if title.contains("invest") || title.contains("stock") || title.contains("crypto") || title.contains("trading") { - return "📈" - } - // Workout/Gym - if title.contains("workout") || title.contains("gym") || title.contains("exercise") || title.contains("lift") - || title.contains("muscle") || title.contains("strength") || title.contains("pushup") || title.contains("pullup") - { - return "💪" - } - // Running/Cardio - if title.contains("run") || title.contains("marathon") || title.contains("jog") || title.contains("cardio") - || title.contains("steps") || title.contains("walk") || title.contains("mile") || title.contains("km") - { - return "🏃" - } - // Weight/Diet - if title.contains("weight") || title.contains("lose") || title.contains("fat") || title.contains("diet") - || title.contains("calories") || title.contains("kg") || title.contains("lbs") || title.contains("pounds") - { - return "⚖️" - } - // Meditation/Yoga - if title.contains("meditat") || title.contains("mindful") || title.contains("yoga") || title.contains("breath") - || title.contains("calm") || title.contains("peace") || title.contains("zen") - { - return "🧘" - } - // Sleep - if title.contains("sleep") || title.contains("rest") || title.contains("hours") { - return "😴" - } - // Water/Hydration - if title.contains("water") || title.contains("hydrat") || title.contains("drink") { - return "💧" - } - // Health - if title.contains("health") || title.contains("wellness") || title.contains("healthy") { - return "❤️" - } - // Reading - if title.contains("read") || title.contains("book") || title.contains("pages") || title.contains("chapter") { - return "📚" - } - // Learning - if title.contains("learn") || title.contains("study") || title.contains("course") || title.contains("class") - || title.contains("skill") || title.contains("certif") - { - return "🎓" - } - // Coding - if title.contains("code") || title.contains("program") || title.contains("develop") || title.contains("app") - || title.contains("software") || title.contains("tech") - { - return "💻" - } - // Language - if title.contains("language") || title.contains("spanish") || title.contains("french") || title.contains("chinese") - || title.contains("english") || title.contains("german") - { - return "🗣️" - } - // Writing - if title.contains("write") || title.contains("blog") || title.contains("article") || title.contains("post") - || title.contains("content") || title.contains("words") - { - return "✍️" - } - // Video - if title.contains("video") || title.contains("youtube") || title.contains("tiktok") || title.contains("film") { - return "🎬" - } - // Music - if title.contains("music") || title.contains("song") || title.contains("piano") || title.contains("guitar") - || title.contains("sing") - { - return "🎵" - } - // Art - if title.contains("art") || title.contains("draw") || title.contains("paint") || title.contains("design") - || title.contains("create") - { - return "🎨" - } - // Photo - if title.contains("photo") || title.contains("picture") || title.contains("camera") { - return "📸" - } - // Tasks - if title.contains("task") || title.contains("todo") || title.contains("complete") || title.contains("finish") - || title.contains("done") - { - return "✅" - } - // Habits - if title.contains("habit") || title.contains("daily") || title.contains("streak") || title.contains("consistent") - || title.contains("routine") - { - return "🔥" - } - // Time/Focus - if title.contains("time") || title.contains("hour") || title.contains("minute") || title.contains("focus") - || title.contains("pomodoro") || title.contains("productive") - { - return "⏰" - } - // Project/Ship - if title.contains("project") || title.contains("ship") || title.contains("deliver") || title.contains("deadline") - || title.contains("feature") - { - return "🎯" - } - // Travel - if title.contains("travel") || title.contains("trip") || title.contains("visit") || title.contains("country") - || title.contains("city") || title.contains("vacation") - { - return "✈️" - } - // Home - if title.contains("home") || title.contains("house") || title.contains("apartment") || title.contains("move") - || title.contains("buy") - { - return "🏠" - } - // Saving - if title.contains("save") || title.contains("saving") || title.contains("budget") - || title.contains("emergency fund") - { - return "🏦" - } - // Social - if title.contains("friend") || title.contains("social") || title.contains("network") || title.contains("connect") - || title.contains("meet") || title.contains("outreach") - { - return "👥" - } - // Family - if title.contains("family") || title.contains("kids") || title.contains("parent") { - return "👨‍👩‍👧" - } - // Relationship - if title.contains("date") || title.contains("relationship") || title.contains("love") { - return "💕" - } - // Win/Success - if title.contains("win") || title.contains("first") || title.contains("best") || title.contains("top") - || title.contains("champion") - { - return "🏆" - } - // Growth/Improve - if title.contains("grow") || title.contains("improve") || title.contains("better") || title.contains("progress") { - return "🌱" - } - // Star/Success - if title.contains("star") || title.contains("success") || title.contains("excellent") { - return "⭐" - } - - // Default - return "🎯" - } -} - -// MARK: - Goal Edit Sheet - -struct GoalEditSheet: View { - let goal: Goal? - let onSave: (String, Double, Double) -> Void - let onDelete: (() -> Void)? - let onDismiss: () -> Void - - @State private var title: String = "" - @State private var currentValue: String = "0" - @State private var targetValue: String = "100" - @State private var selectedEmoji: String = "🎯" - - private let availableEmojis = [ - "🎯", "💪", "📚", "💰", "🏃", "🧘", "💡", "🔥", - "⭐", "🚀", "💎", "🏆", "📈", "❤️", "🎨", "🎵", - "✈️", "🏠", "🌱", "⏰", - ] - - var isNewGoal: Bool { goal == nil } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text(isNewGoal ? "Add Goal" : "Edit Goal") - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundColor(Ink.primary) - - Spacer() - - Button(action: onDismiss) { - Image(systemName: "xmark") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - .frame(width: 28, height: 28) - .background(Ink.rowFillHover.opacity(0.5)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - } - .padding(.horizontal, OmiSpacing.xl) - .padding(.top, OmiSpacing.xl) - .padding(.bottom, OmiSpacing.lg) - - Divider() - .background(Ink.rowFillHover) - - ScrollView { - VStack(alignment: .leading, spacing: OmiSpacing.xl) { - - // Title field - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Goal Title") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - TextField("Enter goal title", text: $title) - .textFieldStyle(.plain) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - ) - } - - // Current & Target fields - HStack(spacing: OmiSpacing.md) { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Current") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - TextField("0", text: $currentValue) - .textFieldStyle(.plain) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - ) - } - - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Target") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - TextField("100", text: $targetValue) - .textFieldStyle(.plain) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - ) - } - } - } - .padding(OmiSpacing.xl) - } - - Divider() - .background(Ink.rowFillHover) - - // Actions - HStack(spacing: OmiSpacing.md) { - // Delete button (only for existing goals) - if !isNewGoal, let onDelete = onDelete { - Button(action: { - onDelete() - onDismiss() - }) { - Text("Delete") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.errorRed) - } - .buttonStyle(.plain) - } - - Spacer() - - // Cancel button - Button(action: onDismiss) { - Text("Cancel") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - - // Save button - Button(action: { - let current = Double(currentValue) ?? 0 - let target = Double(targetValue) ?? 100 - onSave(title, current, target) - onDismiss() - }) { - Text(isNewGoal ? "Add Goal" : "Save") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.surface) - .padding(.horizontal, OmiSpacing.xl) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.primary) - ) - } - .buttonStyle(.plain) - .disabled(title.isEmpty) - .opacity(title.isEmpty ? 0.5 : 1) - } - .padding(OmiSpacing.xl) - } - .frame(width: 400, height: isNewGoal ? 320 : 420) - .background(Ink.surface) - .onAppear { - if let goal = goal { - title = goal.title - currentValue = - goal.currentValue == goal.currentValue.rounded() - ? String(format: "%.0f", goal.currentValue) - : String(format: "%.1f", goal.currentValue) - targetValue = - goal.targetValue == goal.targetValue.rounded() - ? String(format: "%.0f", goal.targetValue) - : String(format: "%.1f", goal.targetValue) - } - } - } -} - -// MARK: - Goal Advice Sheet - -struct GoalInsightSheet: View { - let goal: Goal - let onDismiss: () -> Void - - @State private var isLoading = true - @State private var insight: String? = nil - @State private var errorMessage: String? = nil - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - HStack(spacing: OmiSpacing.sm) { - Image(systemName: "lightbulb.fill") - .scaledFont(size: OmiType.subheading) - .foregroundColor(.yellow) - Text("Goal Insight") - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundColor(Ink.primary) - } - - Spacer() - - Button(action: onDismiss) { - Image(systemName: "xmark") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - .frame(width: 28, height: 28) - .background(Ink.rowFillHover.opacity(0.5)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - } - .padding(.horizontal, OmiSpacing.xl) - .padding(.top, OmiSpacing.xl) - .padding(.bottom, OmiSpacing.lg) - - Divider() - .background(Ink.rowFillHover) - - // Goal info - HStack(spacing: OmiSpacing.md) { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text(goal.title) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.primary) - .lineLimit(1) - - Text("\(Int(goal.currentValue))/\(Int(goal.targetValue)) (\(Int(goal.progress))%)") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - - Spacer() - - // Progress indicator - ZStack { - Circle() - .stroke(Ink.rowFillHover, lineWidth: 3) - Circle() - .trim(from: 0, to: min(goal.progress / 100, 1.0)) - .stroke(Ink.primary, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } - .frame(width: 36, height: 36) - } - .padding(.horizontal, OmiSpacing.xl) - .padding(.vertical, OmiSpacing.md) - .background(Ink.rowFillHover.opacity(0.3)) - - // Content - VStack(spacing: OmiSpacing.lg) { - if isLoading { - VStack(spacing: OmiSpacing.md) { - ProgressView() - .scaleEffect(1.2) - Text("Getting personalized insight...") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = errorMessage { - VStack(spacing: OmiSpacing.md) { - Image(systemName: "exclamationmark.triangle") - .scaledFont(size: 32) - .foregroundColor(PageGlass.warning) - Text(error) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let insightText = insight { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - Text("This week's action:") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - Text(insightText) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(OmiSpacing.xl) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - .background(Ink.rowFillHover) - - // Actions - HStack(spacing: OmiSpacing.md) { - // Refresh button - Button(action: loadInsight) { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "arrow.clockwise") - .scaledFont(size: OmiType.caption) - Text("Refresh") - } - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - .disabled(isLoading) - - Spacer() - - // Done button - Button(action: onDismiss) { - Text("Done") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.surface) - .padding(.horizontal, OmiSpacing.xl) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.primary) - ) - } - .buttonStyle(.plain) - } - .padding(OmiSpacing.xl) - } - .frame(width: 400, height: 380) - .background(Ink.surface) - .onAppear { - loadInsight() - } - } - - private func loadInsight() { - isLoading = true - errorMessage = nil - - Task { - do { - let result = try await GoalsAIService.shared.getGoalInsight(goal: goal) - await MainActor.run { - insight = result - isLoading = false - } - } catch { - await MainActor.run { - errorMessage = UserFacingErrorPresentation.message(for: error, while: .goals) - isLoading = false - } - } - } - } -} - -// MARK: - Goal Header Button with Tooltip - -private struct GoalHeaderButton: View { - let icon: String - let tooltip: String - let color: Color - var isLoading: Bool = false - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button(action: action) { - if isLoading { - ProgressView() - .scaleEffect(0.6) - .frame(width: 14, height: 14) - } else { - Image(systemName: icon) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(color) - } - } - .buttonStyle(.plain) - .disabled(isLoading) - .overlay(alignment: .bottom) { - if isHovered { - Text(tooltip) - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(Ink.rowFillHover) - .shadow(color: .black.opacity(0.08), radius: 4, y: 2) - ) - .fixedSize() - .offset(y: 24) - .transition(.opacity) - } - } - .onHover { hovering in - OmiMotion.withGated(.easeInOut(duration: 0.15)) { - isHovered = hovering - } - } - } -} - -#if canImport(PreviewsMacros) - #Preview { - GoalsWidget( - goals: [], - onCreateGoal: { _, _, _ in }, - onUpdateGoal: { _, _, _, _ in }, - onUpdateProgress: { _, _ in }, - onDeleteGoal: { _ in } - ) - .frame(width: 350) - .padding() - .background(Ink.surface) - } -#endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift index ce534ed62d2..90ad105fff9 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift @@ -13,14 +13,22 @@ import OmiTheme /// - Thematic breaks render as a quiet, branded section divider rather than /// leaking their Markdown source (`---`) into a response. /// -/// Live chat Markdown deliberately disables native SwiftUI text selection. Even -/// settled messages still participate in transcript loading, scrolling, window -/// resizing, and parent-state updates. AppKit-backed selection overlays can turn -/// those updates into a non-converging font/intrinsic-size/layout loop. +/// Live chat Markdown deliberately disables native SwiftUI text selection, and +/// **there is no opt-in** — not per host, not for settled rows. PR #10834 tried +/// exactly that and reopened FC-selection-overlay-layout-loop in Omi Beta +/// 0.12.146: every sampled main-thread stack sat in `SelectionOverlay`, +/// `setFont`, intrinsic-size invalidation and AttributeGraph while memory grew +/// without bound. Settled rows are not safe either — they still participate in +/// transcript loading, scrolling, window resizing and parent-state updates. +/// `.github/scripts/check_chat_selection_boundary.py` enforces this. /// -/// Chat bubbles retain whole-message copy actions, while code blocks and tables -/// keep their focused copy controls. A future selectable reading surface must be -/// isolated from the live transcript instead of adding an escape hatch here. +/// A reader who needs to drag a date out of an answer no longer opens a popover +/// beside the row: `appKitProseSelection` draws that prose through +/// `ChatSelectableProse` instead, where one `NSTextView` owns one selection and +/// installs no overlay for a rebuild to thrash. That is not an opt-in to the +/// paragraph above — it is the other half of it, and the boundary check guards +/// both files. Chat bubbles keep their whole-message copy action, and code +/// blocks and tables keep their own focused copy controls. struct OmiMarkdown: View { enum Style: Equatable { case assistant @@ -32,19 +40,26 @@ struct OmiMarkdown: View { let style: Style let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? + /// Draw prose through `ChatSelectableProse` (one `NSTextView`) instead of + /// SwiftUI `Text`, so the reader can drag across it. This is **not** the + /// banned SwiftUI selection: no `SelectionOverlay` is installed anywhere on + /// this path, which is the whole distinction the boundary is drawing. + let appKitProseSelection: Bool @Environment(\.fontScale) private var fontScale init( text: String, sender: ChatSender, citations: [ChatCitationReference] = [], - onOpenCitation: ((ChatCitationReference) -> Void)? = nil + onOpenCitation: ((ChatCitationReference) -> Void)? = nil, + appKitProseSelection: Bool = false ) { let style: Style = sender == .user ? .user : .assistant self.text = Self.renderableText(text, style: style) self.style = style self.citations = citations self.onOpenCitation = onOpenCitation + self.appKitProseSelection = appKitProseSelection } init(text: String, style: Style) { @@ -52,6 +67,7 @@ struct OmiMarkdown: View { self.style = style self.citations = [] self.onOpenCitation = nil + self.appKitProseSelection = false } /// Assistant text may open with an Interject classification token; it is @@ -63,7 +79,7 @@ struct OmiMarkdown: View { var body: some View { Group { - if citations.isEmpty { + if citations.isEmpty && !appKitProseSelection { OmiMarkdownContent(text: text, style: style, fontScale: fontScale) .equatable() } else { @@ -72,7 +88,8 @@ struct OmiMarkdown: View { style: style, fontScale: fontScale, citations: citations, - onOpenCitation: onOpenCitation) + onOpenCitation: onOpenCitation, + appKitProseSelection: appKitProseSelection) } } .textSelection(.disabled) @@ -97,13 +114,15 @@ struct OmiMarkdownContent: View, Equatable { let document: OmiMarkdownDocument let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? + let appKitProseSelection: Bool init( text: String, style: OmiMarkdown.Style, fontScale: CGFloat, citations: [ChatCitationReference] = [], - onOpenCitation: ((ChatCitationReference) -> Void)? = nil + onOpenCitation: ((ChatCitationReference) -> Void)? = nil, + appKitProseSelection: Bool = false ) { self.text = text self.style = style @@ -111,11 +130,12 @@ struct OmiMarkdownContent: View, Equatable { self.document = OmiMarkdownDocument(markdown: text) self.citations = citations self.onOpenCitation = onOpenCitation + self.appKitProseSelection = appKitProseSelection } nonisolated static func == (lhs: Self, rhs: Self) -> Bool { lhs.text == rhs.text && lhs.style == rhs.style && lhs.fontScale == rhs.fontScale - && lhs.citations == rhs.citations + && lhs.citations == rhs.citations && lhs.appKitProseSelection == rhs.appKitProseSelection } var body: some View { @@ -169,7 +189,16 @@ struct OmiMarkdownContent: View, Equatable { ) Group { - if !citations.isEmpty { + if appKitProseSelection { + // One text view per prose block: selection spans the whole block, and + // the block is the whole message for all but tables and fenced code. + ChatSelectableProseBlock( + text: content, + style: style, + fontScale: fontScale, + citations: citations, + onOpenCitation: onOpenCitation) + } else if !citations.isEmpty { OmiMarkdownCitationContent( text: content, style: style, @@ -321,7 +350,7 @@ struct OmiMarkdownContent: View, Equatable { /// Converts block-level elements (headers, asterisk lists) into inline-compatible /// form for `AttributedString(markdown:)` with `.inlineOnlyPreservingWhitespace`. - static func preprocessText(_ text: String) -> String { + nonisolated static func preprocessText(_ text: String) -> String { text.components(separatedBy: "\n").map { line in var processed = line @@ -1236,7 +1265,7 @@ private struct ChatCitationToken: View { } } -private struct ChatCitationPreview: View { +struct ChatCitationPreview: View { let reference: ChatCitationReference let fontScale: CGFloat let onOpen: () -> Void @@ -1484,7 +1513,8 @@ private struct OmiMarkdownTableView: View { ) .fixedSize(horizontal: false, vertical: true) // Tables do not create one AppKit SelectionOverlay per cell inside the - // live transcript. Copy remains available only on fenced code blocks. + // live transcript. Copy remains available only on fenced code blocks, and + // "Select Text" opens the whole answer on a non-live surface. .textSelection(.disabled) .accessibilityElement(children: .contain) .accessibilityIdentifier("omi-markdown-table") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift index cf358509fd8..605cb9bd968 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift @@ -6,7 +6,7 @@ extension OmiMarkdownContent { /// Tracks the chat font-size setting (`fontSize` already includes `fontScale`). /// It does not grow with window size — line leading that followed the panel /// would loosen on a large display and tighten on a small one. - static func chatLineSpacing(fontSize: CGFloat) -> CGFloat { + nonisolated static func chatLineSpacing(fontSize: CGFloat) -> CGFloat { round(5 * fontSize / 14) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift deleted file mode 100644 index cf624df9ae5..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift +++ /dev/null @@ -1,62 +0,0 @@ -import OmiTheme -import SwiftUI - -struct RecentConversationsWidget: View { - let conversations: [ServerConversation] - let folders: [Folder] - let onViewAll: () -> Void - let onMoveToFolder: (String, String?) async -> Void - var appState: AppState - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - // Header - HStack { - Text("Recent Conversations") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Spacer() - - Button(action: onViewAll) { - Text("View All") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - } - - if conversations.isEmpty { - VStack(spacing: OmiSpacing.sm) { - Text("No conversations yet") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.lg) - } else { - VStack(spacing: OmiSpacing.xxs) { - ForEach(conversations) { conversation in - ConversationRowView( - conversation: conversation, - onTap: onViewAll, - folders: folders, - onMoveToFolder: onMoveToFolder, - isCompactView: true, - appState: appState - ) - } - } - } - } - .padding(OmiSpacing.xl) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius) - .stroke(Ink.rowFillHover.opacity(0.5), lineWidth: 1) - ) - ) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift index b3d04c30ea5..e47c46568c0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift @@ -64,6 +64,9 @@ struct TaskChatPanel: View { onLoadMore: {}, onRate: { _, _, _ in }, localSendToken: taskState.localSendToken, + // The task panel renders the same interactable content blocks as the + // main window; taps route the one shell (`ChatFirstRichBlockContext.auxiliary`). + chatFirstRichBlockContext: .auxiliary(chatProvider: coordinator.chatProvider), enablesPromptTimeline: false, // This thread is about one task; the day's summary belongs in the main chat. showsDailySummary: false, diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift deleted file mode 100644 index 291cdf8cb87..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift +++ /dev/null @@ -1,177 +0,0 @@ -import OmiTheme -import SwiftUI - -struct TasksWidget: View { - let overdueTasks: [TaskActionItem] - let todaysTasks: [TaskActionItem] - let recentTasks: [TaskActionItem] - let onToggleCompletion: (TaskActionItem) -> Void - - private var totalTaskCount: Int { - overdueTasks.count + todaysTasks.count + recentTasks.count - } - - /// Combine overdue + today tasks into one "Today" section (like Flutter) - private var combinedTodayTasks: [TaskActionItem] { - // Sort: overdue first (by due date), then today's tasks (by due date) - let sorted = (overdueTasks + todaysTasks).sorted { a, b in - guard let aDate = a.dueAt, let bDate = b.dueAt else { return a.dueAt != nil } - return aDate < bDate - } - return sorted - } - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.lg) { - // Header - HStack { - Text("Tasks") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - } - - if totalTaskCount == 0 { - // Empty state — vertically centered in the cell - VStack(spacing: 0) { - Spacer(minLength: 0) - - VStack(spacing: OmiSpacing.sm) { - Image(systemName: "checkmark.circle") - .scaledFont(size: OmiType.title) - .foregroundColor(Ink.secondary) - Text("No incomplete tasks") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - let allTasks = (combinedTodayTasks + recentTasks).prefix(3) - - // Task rows + "View all" centered vertically in remaining - // cell height — when the Goals card is taller, the row - // group floats to the middle instead of pinning to the top. - VStack(spacing: 0) { - Spacer(minLength: 0) - - VStack(spacing: OmiSpacing.sm) { - ForEach(Array(allTasks)) { task in - TaskRowView( - task: task, - onToggle: { onToggleCompletion(task) } - ) - } - } - - Button(action: { - NotificationCenter.default.post( - name: .navigateToTasks, - object: nil - ) - }) { - HStack { - Spacer() - Text("View all tasks") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.secondary) - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - Spacer() - } - } - .buttonStyle(.plain) - .padding(.top, OmiSpacing.sm) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .padding(OmiSpacing.xl) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .glassCard() - } -} - -// MARK: - Task Row View - -struct TaskRowView: View { - let task: TaskActionItem - let onToggle: () -> Void - - @State private var isToggling = false - - var body: some View { - HStack(spacing: OmiSpacing.md) { - // Checkbox - Button(action: { - guard !isToggling else { return } - isToggling = true - onToggle() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - isToggling = false - } - }) { - Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") - .scaledFont(size: OmiType.heading) - .foregroundColor(task.completed ? Ink.primary : Ink.secondary) - } - .buttonStyle(.plain) - .disabled(isToggling) - .opacity(isToggling ? 0.5 : 1) - - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - HStack(spacing: OmiSpacing.xs) { - Text(task.description) - .scaledFont(size: OmiType.body) - .foregroundColor(task.completed ? Ink.secondary : Ink.primary) - .strikethrough(task.completed) - .lineLimit(2) - - if task.recurrenceRule == "daily" { - Image(systemName: "repeat") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - } - } - - if task.recurrenceRule == "daily" { - Text("Daily") - .scaledFont(size: OmiType.micro, weight: .medium) - .foregroundColor(Ink.secondary) - .padding(.horizontal, OmiSpacing.xs) - .padding(.vertical, OmiSpacing.hairline) - .background( - RoundedRectangle(cornerRadius: OmiChrome.stripRadius) - .fill(Ink.rowFillHover) - ) - } - } - - Spacer() - } - .padding(.vertical, OmiSpacing.sm) - .padding(.horizontal, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill(task.completed ? Ink.rowFill.opacity(0.55) : Ink.rowFillHover.opacity(0.45)) - ) - } -} - -#if canImport(PreviewsMacros) - #Preview { - TasksWidget( - overdueTasks: [], - todaysTasks: [], - recentTasks: [], - onToggleCompletion: { _ in } - ) - .frame(width: 350) - .padding() - .background(Ink.surface) - } -#endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+DailySummary.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+DailySummary.swift index cfeda64cd98..798c9687b6f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+DailySummary.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+DailySummary.swift @@ -4,8 +4,8 @@ import Foundation /// /// Phases only. The summary's headline, overview, highlights, and action items are the user's own /// day; none of it goes to PostHog (desktop `AGENTS.md` → product analytics integrity). What we -/// need to know is whether the card is seen, opened, and acted on — five bounded values answer -/// that. +/// need to know is whether the card is seen, opened, acted on, and cleared — six bounded values +/// answer that. enum DailySummaryTelemetryPhase: String { /// The card rendered at the top of the thread. case shown @@ -17,6 +17,8 @@ enum DailySummaryTelemetryPhase: String { case cardShown = "card_shown" /// That notch card was opened. case cardTapped = "card_tapped" + /// The reader cleared Chat and the card went with the thread. + case cardDismissed = "card_dismissed" } extension AnalyticsManager { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+HomeKnows.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+HomeKnows.swift deleted file mode 100644 index 4420c507001..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+HomeKnows.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -// MARK: - Home knows-list rotation telemetry -// -// The repetition this event exists to measure: over 14 days the owner's hub -// showed the same four commitments 8–12 times each, and the only engagement was -// asking the card to explain itself. `shows_before` makes that rate readable — -// a healthy list is dominated by 0, a repeating one by 3+. `rotated_out_reason` -// says why a slot went empty instead of repeating. -// -// Bounded dimensions only: kind, slot, and reason are closed enum rawValues and -// `shows_before` is a small integer. The row's text is the reader's own tasks -// and questions and never leaves the device (desktop `AGENTS.md` → product -// analytics integrity). - -extension AnalyticsManager { - static let homeKnowsRowEvent = "desktop_home_knows_row" - - /// One row rendered in the knows-list. Emitted once per visit per row, not - /// once per re-render — the in-visit rotation timer re-renders every few - /// seconds and would otherwise invent impressions. - func trackHomeKnowsRowShown(kind: String, slot: HomeKnowsSlot, showsBefore: Int) { - PostHogManager.shared.track( - Self.homeKnowsRowEvent, - properties: [ - "kind": kind, - "slot": slot.rawValue, - "shows_before": showsBefore, - ]) - } - - /// A slot that stayed empty rather than repeating a row already seen. - func trackHomeKnowsSlotEmpty(slot: HomeKnowsSlot, reason: HomeKnowsRotationReason) { - PostHogManager.shared.track( - Self.homeKnowsRowEvent, - properties: [ - "kind": "empty", - "slot": slot.rawValue, - "shows_before": 0, - "rotated_out_reason": reason.rawValue, - ]) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCoordinator.swift index 6b6694f7674..b141dcf224e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCoordinator.swift @@ -22,6 +22,17 @@ final class ChatDailySummaryCoordinator: ObservableObject { let store: HomeDailySummaryStore + /// True while the summary on hand is the one the owner cleared out of Chat. + /// + /// Clearing the transcript is a statement about the whole surface, not about + /// the rows the journal happens to own. The card is chrome above the thread + /// (INV-CHAT-1 keeps transcript authorship in the kernel, so nothing here + /// writes a turn) and a journal clear cannot reach it — which left the day's + /// summary sitting alone in a chat the reader had just emptied. Clearing + /// records the summary it was showing instead, and the card stays away until + /// a newer day's summary arrives. + @Published private(set) var isClearedFromTranscript = false + private let defaults: UserDefaults private let cardSink: CardSink private let ownerID: () -> String? @@ -63,6 +74,7 @@ final class ChatDailySummaryCoordinator: ObservableObject { func refreshIfNeeded() async { guard ownerID() != nil else { return } await store.refreshIfNeeded() + refreshClearedState() announceIfNew() } @@ -71,9 +83,33 @@ final class ChatDailySummaryCoordinator: ObservableObject { func refresh() async { guard ownerID() != nil else { return } await store.refresh() + refreshClearedState() announceIfNew() } + // MARK: - Clearing + + /// Chat was cleared. Take the card with it. + /// + /// Recording the id rather than a flag is what lets tomorrow's summary come + /// back on its own: the card is withdrawn only while the summary on hand is + /// the one that was on screen when the reader cleared. + func noteChatCleared() { + guard let owner = ownerID(), let record = store.latest else { return } + defaults.set(record.id, forKey: ScopedDefaultsKey.dailySummaryClearedID(ownerID: owner)) + isClearedFromTranscript = true + AnalyticsManager.shared.trackDailySummary(.cardDismissed) + } + + private func refreshClearedState() { + guard let owner = ownerID(), let record = store.latest else { + isClearedFromTranscript = false + return + } + isClearedFromTranscript = + defaults.string(forKey: ScopedDefaultsKey.dailySummaryClearedID(ownerID: owner)) == record.id + } + // MARK: - New-summary announcement /// Desktop has no FCM registration and no remote-notification delegate, so the `daily_summary` diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift deleted file mode 100644 index 7c1ca2470b6..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift +++ /dev/null @@ -1,904 +0,0 @@ -import Foundation - -protocol DashboardIntelligenceClient: AnyObject, Sendable { - func getCandidateWorkflowControl() async throws -> OmiAPI.TaskWorkflowControl - func getWhatMattersNow(deviceID: String?) async throws -> OmiAPI.WhatMattersNowProjection - func getCanonicalGoals(includeEnded: Bool) async throws -> [OmiAPI.GoalResponse] - func getCanonicalGoalDetail(goalID: String) async throws -> OmiAPI.GoalDetailProjection - func getCanonicalCandidate(candidateID: String) async throws -> OmiAPI.CandidateRecord - func getActionItem(id: String) async throws -> TaskActionItem - func createCanonicalGoal( - title: String, desiredOutcome: String, whyItMatters: String?, successCriteria: [String], - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse - func recordTaskFeedback( - _ request: OmiAPI.FeedbackCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.FeedbackRecord - func createTaskOutcome( - _ request: OmiAPI.OutcomeCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.OutcomeRecord - func focusCanonicalGoal( - goalID: String, replacementGoalID: String?, focusRank: Int?, accountGeneration: Int, - idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse - func unfocusCanonicalGoal( - goalID: String, accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse - func transitionCanonicalGoal( - goalID: String, status: OmiAPI.GoalStatus, relationshipDisposition: String, - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse -} - -extension APIClient: @preconcurrency DashboardIntelligenceClient {} - -@MainActor -final class TaskNavigationRequestStore { - static let shared = TaskNavigationRequestStore() - enum Target: Equatable { - case task(String) - case candidate(String) - } - - private(set) var pendingTarget: Target? - private(set) var pendingTask: TaskActionItem? - private(set) var pendingCandidate: OmiAPI.CandidateRecord? - private var runtimeOwnerObserver: NSObjectProtocol? - - init() { - runtimeOwnerObserver = NotificationCenter.default.addObserver( - forName: .runtimeOwnerDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in self?.clear() } - } - } - - func request(task: TaskActionItem) { - pendingTarget = .task(task.id) - pendingTask = task - pendingCandidate = nil - } - - func request(candidate: OmiAPI.CandidateRecord) { - pendingTarget = .candidate(candidate.candidateId) - pendingTask = nil - pendingCandidate = candidate - } - - func peek() -> Target? { - pendingTarget - } - - func consumeIfAvailable(taskIDs: Set<String>, candidateIDs: Set<String>) -> Target? { - guard let target = pendingTarget else { return nil } - let isAvailable: Bool - switch target { - case .task(let id): isAvailable = taskIDs.contains(id) - case .candidate(let id): isAvailable = candidateIDs.contains(id) - } - guard isAvailable else { return nil } - clear() - return target - } - - private func clear() { - pendingTarget = nil - pendingTask = nil - pendingCandidate = nil - } -} - -enum DashboardRecommendationDestination: Equatable { - case suggested(candidateID: String) - case task(taskID: String, workstreamID: String?) - case thread(workstreamID: String, taskID: String?) - case unavailable -} - -struct DashboardRecommendation: Identifiable, Equatable { - let id: String - let interventionID: String - let outputVersion: String - let subjectKind: OmiAPI.RecommendationSubjectKind - let subjectID: String - let feedbackSubjectKind: OmiAPI.FeedbackSubjectKind - let feedbackSubjectID: String - let headline: String - let whyNow: String - let contextLabel: String? - let recommendedAction: String - let evidencePreview: String - let evidenceCount: Int - let dedupeKey: String - let expiresAt: String - let destination: DashboardRecommendationDestination -} - -struct PendingDashboardFeedback: Codable { - let request: OmiAPI.FeedbackCreate - let idempotencyKey: String - let accountGeneration: Int -} - -protocol DashboardFeedbackOutboxPersisting: AnyObject { - func currentOwnerID() -> String - func load(ownerID: String) -> [PendingDashboardFeedback] - func save(_ entries: [PendingDashboardFeedback], ownerID: String) -} - -final class DashboardFeedbackOutboxDefaults: DashboardFeedbackOutboxPersisting { - private let defaults: UserDefaults - private let fixedOwnerID: String? - - init(defaults: UserDefaults = .standard, ownerID: String? = nil) { - self.defaults = defaults - fixedOwnerID = ownerID - } - - func currentOwnerID() -> String { - fixedOwnerID ?? defaults.string(forKey: .authUserId) ?? "signed-out" - } - - private func key(ownerID: String) -> String { "whatMattersNowFeedbackOutbox.v1.\(ownerID)" } - - func load(ownerID: String) -> [PendingDashboardFeedback] { - guard let data = defaults.data(forKey: key(ownerID: ownerID)) else { return [] } - return (try? JSONDecoder().decode([PendingDashboardFeedback].self, from: data)) ?? [] - } - - func save(_ entries: [PendingDashboardFeedback], ownerID: String) { - defaults.set(try? JSONEncoder().encode(entries), forKey: key(ownerID: ownerID)) - } -} - -@MainActor -final class DashboardIntelligenceStore: ObservableObject { - private struct OwnerScope: Equatable { - let ownerID: String - let revision: UInt - } - - @Published private(set) var recommendations: [DashboardRecommendation] = [] - @Published private(set) var goals: [OmiAPI.GoalResponse] = [] - @Published private(set) var selectedGoalDetail: OmiAPI.GoalDetailProjection? - @Published private(set) var isLoading = false - @Published private(set) var accountGeneration: Int? - @Published private(set) var focusReplacementGoalID: String? - @Published var error: String? - - private let client: any DashboardIntelligenceClient - private let outboxStore: any DashboardFeedbackOutboxPersisting - private let now: () -> Date - private let deviceID: () -> String? - private let reportAttribution: (TaskIntelligenceAttributionEvent) -> Void - private var activeOwnerID: String - private var ownerRevision: UInt = 0 - private var activeLoadToken: UUID? - private var loadingOwnerID: String? - /// The in-flight same-owner load, so a concurrent `load()` (e.g. from - /// `openRecommendation`) can await the real fetch instead of returning a no-op - /// and then acting on a still-empty `recommendations`. - private var activeLoadTask: Task<Void, Never>? - private var activeLoadTaskID: UUID? - private var pendingFeedback: [PendingDashboardFeedback] - private var presentedInterventionIDs = Set<String>() - private var didRegisterAutomationActions = false - private var recommendationActionHandler: ((DashboardRecommendation) async -> Bool)? - - init( - client: any DashboardIntelligenceClient = APIClient.shared, - outboxStore: any DashboardFeedbackOutboxPersisting = DashboardFeedbackOutboxDefaults(), - now: @escaping () -> Date = Date.init, - deviceIDProvider: (() -> String?)? = nil, - reportAttribution: ((TaskIntelligenceAttributionEvent) -> Void)? = nil - ) { - self.client = client - self.outboxStore = outboxStore - self.now = now - self.deviceID = deviceIDProvider ?? { ClientDeviceService.shared.clientDeviceId } - self.reportAttribution = - reportAttribution ?? { AnalyticsManager.shared.taskIntelligenceAttribution($0) } - let ownerID = outboxStore.currentOwnerID() - activeOwnerID = ownerID - self.pendingFeedback = outboxStore.load(ownerID: ownerID) - } - - var focusedGoals: [OmiAPI.GoalResponse] { - goals.filter { $0.status == .focused } - .sorted { ($0.focusRank ?? Int.max, $0.updatedAt) < ($1.focusRank ?? Int.max, $1.updatedAt) } - } - - var currentGoals: [OmiAPI.GoalResponse] { - goals.filter { $0.status != .achieved && $0.status != .abandoned } - } - - var endedGoals: [OmiAPI.GoalResponse] { - goals.filter { $0.status == .achieved || $0.status == .abandoned } - } - - func load() async { - let ownerScope = captureOwnerScope() - if loadingOwnerID == ownerScope.ownerID { - // A same-owner load is already running. Await it rather than returning a - // no-op, so callers that depend on the fetched data see the populated - // result instead of a stale/empty one. - if let activeLoadTask { await activeLoadTask.value } - return - } - // Claim the dedup slot SYNCHRONOUSLY here — before spawning the task and - // before the first await. performLoad() runs inside the Task (asynchronously), - // so if we relied on it to set loadingOwnerID, a re-entrant same-owner load() - // could run on the MainActor first, still see nil, and start a second - // concurrent load (overwriting activeLoadTask) — defeating the dedup. - loadingOwnerID = ownerScope.ownerID - let taskID = UUID() - let task = Task { [weak self] in - guard let self else { return } - await self.performLoad(ownerScope: ownerScope) - } - activeLoadTask = task - activeLoadTaskID = taskID - await task.value - if activeLoadTaskID == taskID { - activeLoadTask = nil - activeLoadTaskID = nil - } - } - - private func performLoad(ownerScope: OwnerScope) async { - let loadToken = UUID() - activeLoadToken = loadToken - loadingOwnerID = ownerScope.ownerID - isLoading = true - defer { - if activeLoadToken == loadToken { - activeLoadToken = nil - loadingOwnerID = nil - isLoading = false - } - } - error = nil - - let control: OmiAPI.TaskWorkflowControl - do { - control = try await client.getCandidateWorkflowControl() - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - accountGeneration = nil - recommendations = [] - goals = [] - self.error = UserFacingErrorPresentation.message(for: error, while: .dashboard) - logError("Dashboard: Failed to load workflow control", error: error) - return - } - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - - guard control.workflowMode == .read else { - accountGeneration = nil - recommendations = [] - goals = [] - return - } - accountGeneration = control.accountGeneration - pendingFeedback = outboxStore.load(ownerID: ownerScope.ownerID) - pendingFeedback.removeAll { $0.accountGeneration != control.accountGeneration } - outboxStore.save(pendingFeedback, ownerID: ownerScope.ownerID) - if AccountCutoverOfflineUploadAdmission.allowsUpload() { - await retryPendingFeedback(ownerScope: ownerScope, loadToken: loadToken) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - } - do { - let projection = try await client.getWhatMattersNow(deviceID: deviceID()) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - recommendations = projectForCurrentOwner(projection) - emitPresentedInterventions(recommendations) - } catch APIError.httpError(let statusCode, _) where statusCode == 404 { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - // Users without the intelligence capability retain calm - // dashboard behavior while canonical Goals remain available. - recommendations = [] - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - recommendations = [] - self.error = UserFacingErrorPresentation.message(for: error, while: .dashboard) - logError("Dashboard: What Matters Now projection unavailable", error: error) - } - do { - let loadedGoals = try await client.getCanonicalGoals(includeEnded: true) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - goals = loadedGoals - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - goals = [] - self.error = UserFacingErrorPresentation.message(for: error, while: .dashboard) - logError("Dashboard: Failed to load canonical goals", error: error) - return - } - if error == nil, !pendingFeedback.isEmpty { - error = "Saved feedback will retry automatically." - } - } - - /// Apply a context-triggered canonical projection without coupling dashboard - /// eligibility to notification settings or interruption policy. - func applyContextProjection(_ projection: OmiAPI.WhatMattersNowProjection) { - guard persistenceOwnerIsCurrent else { - refreshOwnerScopedState() - return - } - recommendations = projectForCurrentOwner(projection) - emitPresentedInterventions(recommendations) - error = nil - } - - @discardableResult - func openRecommendation(id: String) async -> Bool { - let ownerScope = captureOwnerScope() - if !recommendations.contains(where: { $0.id == id }) { - await load() - guard requireCurrentOwner(ownerScope) else { return false } - } - guard let recommendation = recommendations.first(where: { $0.id == id }), - let recommendationActionHandler - else { - guard requireCurrentOwner(ownerScope) else { return false } - error = "This review target is no longer available." - return false - } - let opened = await recommendationActionHandler(recommendation) - guard requireCurrentOwner(ownerScope) else { return false } - if opened { - await ContextSubjectBindingService.shared.bindRecentContext( - to: TaskContextSubject( - kind: recommendation.subjectKind, - id: recommendation.subjectID, - workstreamID: Self.destinationWorkstreamID(recommendation.destination) - )) - await recordPrimaryAction(recommendation) - guard requireCurrentOwner(ownerScope) else { return false } - } - return opened - } - - func loadGoalDetail(goalID: String) async { - let ownerScope = captureOwnerScope() - do { - let detail = try await client.getCanonicalGoalDetail(goalID: goalID) - guard requireCurrentOwner(ownerScope) else { return } - selectedGoalDetail = detail - error = nil - } catch { - guard requireCurrentOwner(ownerScope) else { return } - selectedGoalDetail = nil - self.error = "Goal details could not be loaded." - } - } - - func candidateForNavigation(candidateID: String) async -> OmiAPI.CandidateRecord? { - let ownerScope = captureOwnerScope() - do { - let candidate = try await client.getCanonicalCandidate(candidateID: candidateID) - guard requireCurrentOwner(ownerScope) else { return nil } - guard candidate.candidateId == candidateID, - SuggestedTasksStore.canPresentForNavigation(candidate) - else { - error = "This Suggested item is no longer available." - return nil - } - return candidate - } catch { - guard requireCurrentOwner(ownerScope) else { return nil } - self.error = "This Suggested item could not be opened." - return nil - } - } - - func taskForNavigation(taskID: String) async -> TaskActionItem? { - let ownerScope = captureOwnerScope() - do { - let task = try await client.getActionItem(id: taskID) - guard requireCurrentOwner(ownerScope) else { return nil } - // The detail response is the freshest word on retirement, and it projects - // it through canonical lifecycle status — a recommendation minted before - // the task was cancelled/superseded/deleted must not open it as live. - guard task.id == taskID, !task.isRetired else { - error = "This task is no longer available." - return nil - } - return task - } catch { - guard requireCurrentOwner(ownerScope) else { return nil } - self.error = "This task could not be opened." - return nil - } - } - - func clearGoalDetail() { - guard persistenceOwnerIsCurrent else { - refreshOwnerScopedState() - return - } - selectedGoalDetail = nil - } - - func createGoal( - title: String, - desiredOutcome: String, - whyItMatters: String?, - successCriteria: [String], - idempotencyKey: String - ) async -> Bool { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return false } - do { - _ = try await client.createCanonicalGoal( - title: title, - desiredOutcome: desiredOutcome, - whyItMatters: whyItMatters, - successCriteria: successCriteria, - accountGeneration: generation, - idempotencyKey: idempotencyKey - ) - guard requireCurrentOwner(ownerScope) else { return false } - await load() - guard requireCurrentOwner(ownerScope) else { return false } - return true - } catch { - guard requireCurrentOwner(ownerScope) else { return false } - self.error = "Goal could not be created." - return false - } - } - - func recordPrimaryAction(_ recommendation: DashboardRecommendation) async { - let ownerScope = captureOwnerScope() - guard recommendations.contains(recommendation) else { return } - _ = await recordFeedback( - recommendation, - action: .do_now, - reason: nil, - laterUntil: nil, - idempotencyKey: "wmn:\(recommendation.interventionID):do-now", - ownerScope: ownerScope - ) - guard requireCurrentOwner(ownerScope) else { return } - recommendations.removeAll { $0.id == recommendation.id } - } - - func later(_ recommendation: DashboardRecommendation) async { - let ownerScope = captureOwnerScope() - guard recommendations.contains(recommendation) else { return } - let until = now().addingTimeInterval(24 * 60 * 60) - _ = await recordFeedback( - recommendation, - action: .later, - reason: nil, - laterUntil: Self.iso8601(until), - idempotencyKey: - "wmn:\(recommendation.interventionID):later:\(UUID().uuidString.lowercased())", - ownerScope: ownerScope - ) - guard requireCurrentOwner(ownerScope) else { return } - recommendations.removeAll { $0.id == recommendation.id } - } - - func dismiss( - _ recommendation: DashboardRecommendation, - reason: OmiAPI.TaskIntelligenceFeedbackReason? - ) async { - let ownerScope = captureOwnerScope() - guard recommendations.contains(recommendation) else { return } - _ = await recordFeedback( - recommendation, - action: .dismiss, - reason: reason, - laterUntil: nil, - idempotencyKey: "wmn:\(recommendation.interventionID):dismiss:\(reason?.rawValue ?? "none")", - ownerScope: ownerScope - ) - guard requireCurrentOwner(ownerScope) else { return } - recommendations.removeAll { $0.id == recommendation.id } - } - - func focus(goalID: String, replacing replacementGoalID: String?) async -> Bool { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return false } - do { - _ = try await client.focusCanonicalGoal( - goalID: goalID, - replacementGoalID: replacementGoalID, - focusRank: nil, - accountGeneration: generation, - idempotencyKey: "goal-focus:\(goalID):\(UUID().uuidString.lowercased())" - ) - guard requireCurrentOwner(ownerScope) else { return false } - focusReplacementGoalID = nil - await load() - guard requireCurrentOwner(ownerScope) else { return false } - return true - } catch APIError.httpError(let statusCode, _) - where statusCode == 409 && replacementGoalID == nil - { - guard requireCurrentOwner(ownerScope) else { return false } - focusReplacementGoalID = goalID - self.error = "Choose a focused goal to replace." - return false - } catch { - guard requireCurrentOwner(ownerScope) else { return false } - focusReplacementGoalID = nil - self.error = "Goal focus could not be updated." - return false - } - } - - func unfocus(goalID: String) async { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return } - do { - _ = try await client.unfocusCanonicalGoal( - goalID: goalID, - accountGeneration: generation, - idempotencyKey: "goal-unfocus:\(goalID):\(UUID().uuidString.lowercased())" - ) - guard requireCurrentOwner(ownerScope) else { return } - await load() - guard requireCurrentOwner(ownerScope) else { return } - } catch { - guard requireCurrentOwner(ownerScope) else { return } - self.error = "Goal focus could not be updated." - } - } - - func transition(goalID: String, status: OmiAPI.GoalStatus) async { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return } - do { - _ = try await client.transitionCanonicalGoal( - goalID: goalID, - status: status, - relationshipDisposition: "retain", - accountGeneration: generation, - idempotencyKey: - "goal-lifecycle:\(goalID):\(status.rawValue):\(UUID().uuidString.lowercased())" - ) - guard requireCurrentOwner(ownerScope) else { return } - await load() - guard requireCurrentOwner(ownerScope) else { return } - } catch { - guard requireCurrentOwner(ownerScope) else { return } - self.error = "Goal lifecycle could not be updated." - } - } - - @discardableResult - private func recordFeedback( - _ recommendation: DashboardRecommendation, - action: OmiAPI.TaskIntelligenceFeedbackAction, - reason: OmiAPI.TaskIntelligenceFeedbackReason?, - laterUntil: String?, - idempotencyKey: String, - ownerScope: OwnerScope - ) async -> OmiAPI.FeedbackRecord? { - guard requireCurrentOwner(ownerScope), let generation = accountGeneration else { return nil } - let request = OmiAPI.FeedbackCreate( - action: action, - contextSnapshotHash: nil, - interventionId: recommendation.interventionID, - laterUntil: laterUntil, - reason: reason, - subjectId: recommendation.feedbackSubjectID, - subjectKind: recommendation.feedbackSubjectKind - ) - let entry = PendingDashboardFeedback( - request: request, - idempotencyKey: idempotencyKey, - accountGeneration: generation - ) - var ownerFeedback = outboxStore.load(ownerID: ownerScope.ownerID) - ownerFeedback.removeAll { $0.idempotencyKey == idempotencyKey } - ownerFeedback.append(entry) - outboxStore.save(ownerFeedback, ownerID: ownerScope.ownerID) - guard requireCurrentOwner(ownerScope) else { return nil } - pendingFeedback = ownerFeedback - do { - let feedback = try await client.recordTaskFeedback( - request, idempotencyKey: idempotencyKey, accountGeneration: generation) - guard requireCurrentOwner(ownerScope) else { return nil } - ownerFeedback = outboxStore.load(ownerID: ownerScope.ownerID) - ownerFeedback.removeAll { $0.idempotencyKey == idempotencyKey } - outboxStore.save(ownerFeedback, ownerID: ownerScope.ownerID) - pendingFeedback = ownerFeedback - error = nil - reportAttribution( - .feedbackRecorded( - interventionID: recommendation.interventionID, - surface: .whatMattersNow, - action: action.rawValue, - reason: reason?.rawValue, - subjectKind: recommendation.feedbackSubjectKind.rawValue, - subjectID: recommendation.feedbackSubjectID, - candidateID: recommendation.subjectKind == .candidate ? recommendation.subjectID : nil, - attributionChainID: feedback.attributionChainId - )) - return feedback - } catch { - guard requireCurrentOwner(ownerScope) else { return nil } - self.error = "Saved. Feedback will retry automatically." - return nil - } - } - - private func emitPresentedInterventions(_ recommendations: [DashboardRecommendation]) { - for recommendation in recommendations { - guard presentedInterventionIDs.insert(recommendation.interventionID).inserted else { - continue - } - reportAttribution( - .interventionPresented( - interventionID: recommendation.interventionID, - surface: .whatMattersNow, - subjectKind: recommendation.subjectKind.rawValue, - subjectID: recommendation.subjectID, - candidateID: recommendation.subjectKind == .candidate ? recommendation.subjectID : nil - )) - } - } - - private func retryPendingFeedback(ownerScope: OwnerScope, loadToken: UUID) async { - var succeeded = Set<String>() - for entry in outboxStore.load(ownerID: ownerScope.ownerID) { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - guard AccountCutoverOfflineUploadAdmission.allowsUpload() else { return } - do { - _ = try await client.recordTaskFeedback( - entry.request, idempotencyKey: entry.idempotencyKey, - accountGeneration: entry.accountGeneration) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - succeeded.insert(entry.idempotencyKey) - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - continue - } - } - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - let remaining = outboxStore.load(ownerID: ownerScope.ownerID).filter { - !succeeded.contains($0.idempotencyKey) - } - outboxStore.save(remaining, ownerID: ownerScope.ownerID) - pendingFeedback = remaining - } - - private var persistenceOwnerIsCurrent: Bool { - activeOwnerID == outboxStore.currentOwnerID() - } - - private func captureOwnerScope() -> OwnerScope { - refreshOwnerScopedState() - return OwnerScope(ownerID: activeOwnerID, revision: ownerRevision) - } - - @discardableResult - private func requireCurrentOwner(_ ownerScope: OwnerScope) -> Bool { - guard ownerScopeIsCurrent(ownerScope) else { - refreshOwnerScopedState() - return false - } - return true - } - - private func ownerScopeIsCurrent(_ ownerScope: OwnerScope) -> Bool { - ownerScope.ownerID == activeOwnerID - && ownerScope.revision == ownerRevision - && outboxStore.currentOwnerID() == ownerScope.ownerID - } - - private func loadScopeIsCurrent(_ ownerScope: OwnerScope, token: UUID) -> Bool { - guard activeLoadToken == token, ownerScopeIsCurrent(ownerScope) else { - refreshOwnerScopedState() - return false - } - return true - } - - @discardableResult - private func refreshOwnerScopedState() -> Bool { - let ownerID = outboxStore.currentOwnerID() - guard ownerID != activeOwnerID else { return false } - - activeOwnerID = ownerID - ownerRevision &+= 1 - activeLoadToken = nil - loadingOwnerID = nil - pendingFeedback = outboxStore.load(ownerID: ownerID) - recommendations = [] - goals = [] - selectedGoalDetail = nil - isLoading = false - accountGeneration = nil - focusReplacementGoalID = nil - presentedInterventionIDs = [] - error = nil - return true - } - - private func projectForCurrentOwner( - _ projection: OmiAPI.WhatMattersNowProjection - ) -> [DashboardRecommendation] { - Self.project(projection, now: now(), pendingFeedback: pendingFeedback) - } - - static func project( - _ projection: OmiAPI.WhatMattersNowProjection, - now: Date - ) -> [DashboardRecommendation] { - project(projection, now: now, pendingFeedback: []) - } - - private static func project( - _ projection: OmiAPI.WhatMattersNowProjection, - now: Date, - pendingFeedback: [PendingDashboardFeedback] - ) -> [DashboardRecommendation] { - guard let projectionExpiry = parseDate(projection.expiresAt), projectionExpiry > now else { - return [] - } - var seenDedupeKeys = Set<String>() - let recommendations = projection.recommendations.compactMap { - item -> DashboardRecommendation? in - let suppressedByPendingFeedback = pendingFeedback.contains { entry in - guard entry.request.action == .later || entry.request.action == .dismiss else { - return false - } - let matchesIntervention = entry.request.interventionId == item.interventionId - let matchesSubject = - entry.request.subjectKind == item.feedbackSubjectKind - && entry.request.subjectId == item.feedbackSubjectId - return matchesIntervention || matchesSubject - } - guard !suppressedByPendingFeedback else { return nil } - guard let expiry = parseDate(item.expiresAt), expiry > now else { return nil } - guard seenDedupeKeys.insert(item.dedupeKey).inserted else { return nil } - let destination: DashboardRecommendationDestination - switch item.subjectKind { - case .candidate: - destination = .suggested(candidateID: item.subjectId) - case .task: - destination = .task( - taskID: item.destinationTaskId ?? item.subjectId, - workstreamID: item.destinationWorkstreamId - ) - case .workstream: - destination = .thread( - workstreamID: item.destinationWorkstreamId ?? item.subjectId, - taskID: item.destinationTaskId - ) - case .artifact, .decision, .agent_open_loop: - guard let workstreamID = item.destinationWorkstreamId else { return nil } - destination = .thread(workstreamID: workstreamID, taskID: item.destinationTaskId) - case ._unknown: - return nil - } - return DashboardRecommendation( - id: "\(item.outputVersion):\(item.dedupeKey)", - interventionID: item.interventionId, - outputVersion: item.outputVersion, - subjectKind: item.subjectKind, - subjectID: item.subjectId, - feedbackSubjectKind: item.feedbackSubjectKind, - feedbackSubjectID: item.feedbackSubjectId, - headline: item.headline, - whyNow: item.whyNow, - contextLabel: item.goalOrWorkstreamLabel, - recommendedAction: item.recommendedAction, - evidencePreview: item.evidencePreview, - evidenceCount: item.evidenceRefs.count, - dedupeKey: item.dedupeKey, - expiresAt: item.expiresAt, - destination: destination - ) - } - return Array(recommendations.prefix(3)) - } - - private static func parseDate(_ value: String) -> Date? { - let precise = ISO8601DateFormatter() - precise.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return precise.date(from: value) ?? ISO8601DateFormatter().date(from: value) - } - - private static func iso8601(_ value: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: value) - } - - func registerAutomationActions() { - guard DesktopAutomationLaunchOptions.isEnabled, !didRegisterAutomationActions else { return } - didRegisterAutomationActions = true - DesktopAutomationActionRegistry.shared.register( - name: "refresh_what_matters_now", - summary: "Refresh canonical recommendations and goals", - params: [] - ) { [weak self] _ in - guard let self else { return ["error": "dashboard intelligence store deallocated"] } - await self.load() - return [ - "recommendations": String(self.recommendations.count), - "focused_goals": String(self.focusedGoals.count), - "output_ids": self.recommendations.map(\.id).joined(separator: ","), - "subjects": self.recommendations.map(Self.automationSummary).joined(separator: ","), - "error": self.error ?? "", - ] - } - DesktopAutomationActionRegistry.shared.register( - name: "open_what_matters_now", - summary: "Open one canonical recommendation by stable output id", - params: ["recommendation_id"] - ) { [weak self] params in - guard let self else { return ["error": "dashboard intelligence store deallocated"] } - guard let recommendationID = params["recommendation_id"], !recommendationID.isEmpty else { - return ["error": "recommendation_id is required"] - } - if !self.recommendations.contains(where: { $0.id == recommendationID }) { await self.load() } - let recommendation = self.recommendations.first(where: { $0.id == recommendationID }) - let opened = await self.openRecommendation(id: recommendationID) - return [ - "success": opened ? "true" : "false", - "subject_kind": recommendation?.subjectKind.rawValue ?? "", - "subject_id": recommendation?.subjectID ?? "", - "destination": recommendation.map { - Self.automationDestination($0.destination) - } ?? "", - "error": self.error ?? "", - ] - } - DesktopAutomationActionRegistry.shared.register( - name: "focus_goal", - summary: "Focus a canonical goal with optional explicit replacement", - params: ["goal_id", "replacement_goal_id"] - ) { [weak self] params in - guard let self else { return ["error": "dashboard intelligence store deallocated"] } - guard let goalID = params["goal_id"], !goalID.isEmpty else { - return ["error": "goal_id is required"] - } - let success = await self.focus(goalID: goalID, replacing: params["replacement_goal_id"]) - return ["success": success ? "true" : "false", "error": self.error ?? ""] - } - } - - func setRecommendationActionHandler(_ handler: ((DashboardRecommendation) async -> Bool)?) { - recommendationActionHandler = handler - } - - private static func automationSummary(_ recommendation: DashboardRecommendation) -> String { - [ - recommendation.id, - recommendation.subjectKind.rawValue, - recommendation.subjectID, - automationDestination(recommendation.destination), - ].joined(separator: "|") - } - - private static func automationDestination(_ destination: DashboardRecommendationDestination) - -> String - { - switch destination { - case .suggested(let candidateID): return "candidate:\(candidateID)" - case .task(let taskID, let workstreamID): return "task:\(taskID):\(workstreamID ?? "")" - case .thread(let workstreamID, let taskID): return "thread:\(workstreamID):\(taskID ?? "")" - case .unavailable: return "unavailable" - } - } - - private static func destinationWorkstreamID(_ destination: DashboardRecommendationDestination) - -> String? - { - switch destination { - case .task(_, let workstreamID): return workstreamID - case .thread(let workstreamID, _): return workstreamID - case .suggested, .unavailable: return nil - } - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift new file mode 100644 index 00000000000..23f8e834f07 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift @@ -0,0 +1,208 @@ +import Combine +import Foundation + +// MARK: - Dashboard View Model + +@MainActor +class DashboardViewModel: ObservableObject { + // Observe the shared TasksStore + private let tasksStore = TasksStore.shared + + @Published var scoreResponse: ScoreResponse? + @Published var goals: [Goal] = [] + @Published var isLoading = false + @Published var error: String? + + private var cancellables = Set<AnyCancellable>() + private var lastGoalRefreshTime: Date = .distantPast + + // Computed properties that delegate to TasksStore + var overdueTasks: [TaskActionItem] { tasksStore.overdueTasks } + var todaysTasks: [TaskActionItem] { tasksStore.todaysTasks } + var recentTasks: [TaskActionItem] { tasksStore.tasksWithoutDueDate } + + init() { + // Forward TasksStore changes to trigger view updates + tasksStore.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + // Load goals from local SQLite for instant display + loadGoalsFromLocal() + + // Refresh goals when one is auto-created + NotificationCenter.default.publisher(for: .goalAutoCreated) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { [weak self] in + await self?.loadGoals() + } + } + .store(in: &cancellables) + } + + func loadDashboardData() async { + isLoading = true + error = nil + + // Load all data in parallel + async let scoreTask: Void = loadScores() + async let tasksTask: Void = tasksStore.refreshDashboardTasksFromServer() + async let goalsTask: Void = loadGoals() + + let _ = await (scoreTask, tasksTask, goalsTask) + + isLoading = false + } + + func loadCachedDashboardData() async { + await loadGoalsFromLocalSnapshot() + } + + func resetSessionState() { + scoreResponse = nil + goals = [] + isLoading = false + error = nil + lastGoalRefreshTime = .distantPast + } + + private func loadScores() async { + do { + scoreResponse = try await APIClient.shared.getScores() + } catch { + logError("Failed to load scores", error: error) + } + } + + private func loadGoals() async { + // 1. Show local data first (already loaded in init) + // 2. Fetch from API + do { + let apiGoals = try await APIClient.shared.getGoals() + // 3. Sync to SQLite + try await GoalStorage.shared.syncServerGoals(apiGoals) + // 4. Reload from SQLite (source of truth) + goals = try await GoalStorage.shared.getLocalGoals() + lastGoalRefreshTime = Date() + } catch { + logError("Failed to load goals", error: error) + } + } + + /// Refresh goals with 30-second debounce (for app lifecycle events) + func refreshGoals() { + let now = Date() + guard now.timeIntervalSince(lastGoalRefreshTime) > 30 else { return } + Task { + await loadGoals() + } + } + + // MARK: - Local Goals Storage + + private func loadGoalsFromLocal() { + Task { + await loadGoalsFromLocalSnapshot() + } + } + + private func loadGoalsFromLocalSnapshot() async { + do { + goals = try await GoalStorage.shared.getLocalGoals() + } catch { + logError("Failed to load goals from local storage", error: error) + } + } + + func toggleTaskCompletion(_ task: TaskActionItem) async { + // Delegate to shared store - it handles the update + await tasksStore.toggleTask(task) + // Reload scores after task completion change + await loadScores() + } + + func createGoal(title: String, goalType: GoalType, targetValue: Double, unit: String?) async { + do { + let goal = try await APIClient.shared.createGoal( + title: title, + goalType: goalType, + targetValue: targetValue, + unit: unit, + source: "user" + ) + _ = try? await GoalStorage.shared.syncServerGoal(goal) + goals = try await GoalStorage.shared.getLocalGoals() + } catch { + logError("Failed to create goal", error: error) + } + } + + func updateGoalProgress(_ goal: Goal, currentValue: Double) async { + log("Goals: Updating '\(goal.title)' progress to \(currentValue)") + + // Optimistically update local SQLite + if let index = goals.firstIndex(where: { $0.id == goal.id }) { + goals[index].currentValue = currentValue + } + try? await GoalStorage.shared.updateProgress(backendId: goal.id, currentValue: currentValue) + + do { + let updated = try await APIClient.shared.updateGoalProgress( + goalId: goal.id, + currentValue: currentValue + ) + + // Sync API response to SQLite + _ = try? await GoalStorage.shared.syncServerGoal(updated) + + // Check if the backend auto-completed this goal + if updated.completedAt != nil { + log("Goals: '\(goal.title)' COMPLETED! Triggering celebration.") + goals = try await GoalStorage.shared.getLocalGoals() + NotificationCenter.default.post(name: .goalCompleted, object: updated) + return + } + + goals = try await GoalStorage.shared.getLocalGoals() + log("Goals: Updated '\(goal.title)' progress confirmed by API") + } catch { + logError("Failed to update goal progress", error: error) + } + } + + func updateGoal(_ goal: Goal, title: String, currentValue: Double, targetValue: Double) async { + log("Goals: Updating goal '\(goal.title)' -> title='\(title)', current=\(currentValue), target=\(targetValue)") + + do { + let updated = try await APIClient.shared.updateGoal( + goalId: goal.id, + title: title, + currentValue: currentValue, + targetValue: targetValue + ) + + _ = try? await GoalStorage.shared.syncServerGoal(updated) + goals = try await GoalStorage.shared.getLocalGoals() + log("Goals: Updated goal '\(updated.title)' confirmed by API") + } catch { + logError("Failed to update goal", error: error) + goals = (try? await GoalStorage.shared.getLocalGoals()) ?? goals + } + } + + func deleteGoal(_ goal: Goal) async { + do { + // Soft-delete locally first for instant UI update + try? await GoalStorage.shared.softDelete(backendId: goal.id) + goals = try await GoalStorage.shared.getLocalGoals() + // Then delete on backend + try await APIClient.shared.deleteGoal(id: goal.id) + } catch { + logError("Failed to delete goal", error: error) + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift deleted file mode 100644 index c47f8e5b95c..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -/// Monotonic generation policy for the deferred Home ask-field focus. -/// -/// `DashboardPage.openHomeChat(focusInput:)` schedules the ask-field focus -/// after a run-loop yield so it lands once the stage transition has rendered. -/// Without invalidation that deferred focus is stale the instant the user — or -/// the automation bridge — connects / collapses / closes before the yield -/// resumes, and a stale focus reopens chat through the focus observer -/// (home-stage S6 regression: expected hub, returned chat before the query -/// completed). -/// -/// Each invalidation bumps the generation; a deferred focus applies only if its -/// token still matches the current generation *and* the stage is still chat. -/// This type is the production seam — pure and deterministic, unit-tested -/// without touching the run loop. -final class HomeAskFocusPolicy { - /// Monotonic invalidation counter. Bumped by every connect / collapse / close. - private(set) var generation: Int = 0 - - /// Captured before scheduling a deferred focus; compared on resume. - struct Token: Equatable { - let generation: Int - } - - /// Snapshot the current generation to pair with a deferred focus. - func currentToken() -> Token { Token(generation: generation) } - - /// Invalidate every outstanding deferred focus. Returns the new generation. - @discardableResult - func invalidate() -> Int { - generation += 1 - return generation - } - - /// True only if `token` was captured against the still-current generation. - func isCurrent(_ token: Token) -> Bool { token.generation == generation } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummarySection.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummarySection.swift deleted file mode 100644 index 7131eb34a3a..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummarySection.swift +++ /dev/null @@ -1,249 +0,0 @@ -import OmiTheme -import SwiftUI - -/// The daily summary on the Home hub: the same record mobile renders, with the stats row. -/// -/// Resting state is one glance: emoji, date, headline, and the numbers. "More" opens the overview, -/// the day's action items, and the topic highlights in place; nothing navigates away, because the -/// hub is the resting surface and the summary is a read, not a task. -struct HomeDailySummarySection: View { - @ObservedObject var store: HomeDailySummaryStore - - @State private var isExpanded = false - @State private var isHovering = false - - var body: some View { - Group { - if let summary = store.latest { - card(summary) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .task { await store.refreshIfNeeded() } - .omiAnimation(.easeOut(duration: 0.2), value: isExpanded) - } - - private func card(_ summary: DailySummaryRecord) -> some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - header(summary) - - if let stats = summary.stats { - HomeDailySummaryStatsRow(stats: stats) - } - - if isExpanded { - expandedBody(summary) - } else if let overview = summary.overview, !overview.isEmpty { - Text(overview) - .scaledFont(size: OmiType.body) - .foregroundStyle(HomePalette.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(.horizontal, OmiSpacing.lg) - .padding(.vertical, OmiSpacing.md + 2) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 13)) - .onHover { isHovering = $0 } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("home-daily-summary") - } - - private func header(_ summary: DailySummaryRecord) -> some View { - HStack(alignment: .firstTextBaseline, spacing: OmiSpacing.sm) { - Text(Self.nonEmpty(summary.dayEmoji) ?? "📅") - .scaledFont(size: OmiType.subheading) - VStack(alignment: .leading, spacing: 2) { - Text(Self.eyebrow(for: summary.date)) - .scaledFont(size: OmiType.micro, weight: .semibold) - .foregroundStyle(HomePalette.muted) - .tracking(0.6) - Text(Self.nonEmpty(summary.headline) ?? "Your day in review") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(isExpanded ? nil : 1) - } - Spacer(minLength: OmiSpacing.sm) - Button(isExpanded ? "Less" : "More") { isExpanded.toggle() } - .buttonStyle(.plain) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.secondary) - .accessibilityIdentifier("home-daily-summary-toggle") - } - } - - @ViewBuilder - private func expandedBody(_ summary: DailySummaryRecord) -> some View { - if let overview = summary.overview, !overview.isEmpty { - Text(overview) - .scaledFont(size: OmiType.body) - .foregroundStyle(HomePalette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - let items = (summary.actionItems ?? []).filter { !($0.description ?? "").isEmpty } - if !items.isEmpty { - VStack(alignment: .leading, spacing: OmiSpacing.xs) { - Text("Action items") - .scaledFont(size: OmiType.micro, weight: .semibold) - .foregroundStyle(HomePalette.muted) - .tracking(0.6) - ForEach(Array(items.prefix(5).enumerated()), id: \.offset) { _, item in - HStack(alignment: .firstTextBaseline, spacing: OmiSpacing.sm) { - Image(systemName: item.completed == true ? "checkmark.circle.fill" : "circle") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(item.completed == true ? HomePalette.green : HomePalette.muted) - Text(item.description ?? "") - .scaledFont(size: OmiType.body) - .foregroundStyle(HomePalette.ink) - .strikethrough(item.completed == true, color: HomePalette.muted) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - - let highlights = (summary.highlights ?? []).filter { !($0.summary ?? "").isEmpty } - if !highlights.isEmpty { - VStack(alignment: .leading, spacing: OmiSpacing.xs) { - Text("Highlights") - .scaledFont(size: OmiType.micro, weight: .semibold) - .foregroundStyle(HomePalette.muted) - .tracking(0.6) - ForEach(Array(highlights.prefix(3).enumerated()), id: \.offset) { _, highlight in - HStack(alignment: .firstTextBaseline, spacing: OmiSpacing.sm) { - Text(Self.nonEmpty(highlight.emoji) ?? "•") - .scaledFont(size: OmiType.caption) - VStack(alignment: .leading, spacing: 1) { - if let topic = highlight.topic, !topic.isEmpty { - Text(topic) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(HomePalette.ink) - } - Text(highlight.summary ?? "") - .scaledFont(size: OmiType.body) - .foregroundStyle(HomePalette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - } - } - - nonisolated static func nonEmpty(_ value: String?) -> String? { - guard let value, !value.isEmpty else { return nil } - return value - } - - /// "Daily summary · Mon, Sep 1" from the backend's `YYYY-MM-DD`; falls back to the bare label when - /// the date is missing or malformed rather than showing a parsing artifact. - nonisolated static func eyebrow( - for date: String?, calendar: Calendar = .current, locale: Locale = .current - ) -> String { - let label = "DAILY SUMMARY" - guard let date else { return label } - let parts = date.split(separator: "-").compactMap { Int($0) } - guard parts.count == 3, - let day = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) - else { return label } - let formatter = DateFormatter() - formatter.calendar = calendar - // The day was built in the calendar's zone; format it there too, or a UTC midnight renders as - // the previous evening in every zone west of it. - formatter.timeZone = calendar.timeZone - formatter.locale = locale - formatter.setLocalizedDateFormatFromTemplate("EEE MMM d") - return "\(label) · \(formatter.string(from: day).uppercased())" - } -} - -/// The numbers, as chips. Only stats the backend actually filled are shown, so a day without -/// desktop usage still reads cleanly instead of showing a row of zeros. -struct HomeDailySummaryStatsRow: View { - let stats: DailySummaryRecord.Stats - - struct Chip: Identifiable, Equatable { - let id: String - let symbol: String - let value: String - let label: String - } - - var body: some View { - let chips = Self.chips(for: stats) - if !chips.isEmpty { - HStack(spacing: OmiSpacing.xs) { - ForEach(chips) { chip in - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: chip.symbol) - .scaledFont(size: OmiType.micro, weight: .semibold) - .foregroundStyle(HomePalette.muted) - Text(chip.value) - .scaledFont(size: OmiType.caption, weight: .semibold) - .monospacedDigit() - .foregroundStyle(HomePalette.ink) - Text(chip.label) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.secondary) - } - .padding(.horizontal, OmiSpacing.sm + 1) - .padding(.vertical, OmiSpacing.xxs + 1) - .background(Capsule().fill(Ink.rowFill)) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(chip.value) \(chip.label)") - } - } - .accessibilityIdentifier("home-daily-summary-stats") - } - } - - /// Pure so the row's contents are testable: a nil or zero stat produces no chip, durations - /// render as `2h 10m` / `45m`, and the order is fixed (time watched, moments, conversations, - /// listening minutes, memories, tasks). - nonisolated static func chips(for stats: DailySummaryRecord.Stats) -> [Chip] { - var chips: [Chip] = [] - if let minutes = stats.watchingMinutes, minutes > 0 { - chips.append(Chip(id: "watching", symbol: "eye", value: duration(minutes), label: "watching")) - } - if let moments = stats.proactiveMoments, moments > 0 { - chips.append(Chip(id: "moments", symbol: "bell", value: "\(moments)", label: moments == 1 ? "moment" : "moments")) - } - if let conversations = stats.totalConversations, conversations > 0 { - chips.append( - Chip( - id: "conversations", symbol: "bubble.left.and.bubble.right", value: "\(conversations)", - label: conversations == 1 ? "conversation" : "conversations")) - } - if let minutes = stats.totalDurationMinutes, minutes > 0 { - chips.append(Chip(id: "listening", symbol: "waveform", value: duration(minutes), label: "listening")) - } - if let memories = stats.memoriesCreated, memories > 0 { - chips.append( - Chip(id: "memories", symbol: "sparkles", value: "\(memories)", label: memories == 1 ? "memory" : "memories")) - } - let tasks = stats.actionItemsCreated ?? stats.actionItemsCount - if let tasks, tasks > 0 { - chips.append( - Chip(id: "tasks", symbol: "checkmark.circle", value: "\(tasks)", label: tasks == 1 ? "task" : "tasks")) - } - return chips - } - - nonisolated static func duration(_ minutes: Int) -> String { - let hours = minutes / 60 - let rest = minutes % 60 - if hours == 0 { return "\(rest)m" } - if rest == 0 { return "\(hours)h" } - return "\(hours)h \(rest)m" - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummaryStatsRow.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummaryStatsRow.swift new file mode 100644 index 00000000000..6850c4636d8 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummaryStatsRow.swift @@ -0,0 +1,83 @@ +import OmiTheme +import SwiftUI + +/// The numbers, as chips. Only stats the backend actually filled are shown, so a day without +/// desktop usage still reads cleanly instead of showing a row of zeros. +struct HomeDailySummaryStatsRow: View { + let stats: DailySummaryRecord.Stats + + struct Chip: Identifiable, Equatable { + let id: String + let symbol: String + let value: String + let label: String + } + + var body: some View { + let chips = Self.chips(for: stats) + if !chips.isEmpty { + HStack(spacing: OmiSpacing.xs) { + ForEach(chips) { chip in + HStack(spacing: OmiSpacing.xxs) { + Image(systemName: chip.symbol) + .scaledFont(size: OmiType.micro, weight: .semibold) + .foregroundStyle(HomePalette.muted) + Text(chip.value) + .scaledFont(size: OmiType.caption, weight: .semibold) + .monospacedDigit() + .foregroundStyle(HomePalette.ink) + Text(chip.label) + .scaledFont(size: OmiType.caption) + .foregroundStyle(HomePalette.secondary) + } + .padding(.horizontal, OmiSpacing.sm + 1) + .padding(.vertical, OmiSpacing.xxs + 1) + .background(Capsule().fill(Ink.rowFill)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(chip.value) \(chip.label)") + } + } + .accessibilityIdentifier("home-daily-summary-stats") + } + } + + /// Pure so the row's contents are testable: a nil or zero stat produces no chip, durations + /// render as `2h 10m` / `45m`, and the order is fixed (time watched, moments, conversations, + /// listening minutes, memories, tasks). + nonisolated static func chips(for stats: DailySummaryRecord.Stats) -> [Chip] { + var chips: [Chip] = [] + if let minutes = stats.watchingMinutes, minutes > 0 { + chips.append(Chip(id: "watching", symbol: "eye", value: duration(minutes), label: "watching")) + } + if let moments = stats.proactiveMoments, moments > 0 { + chips.append(Chip(id: "moments", symbol: "bell", value: "\(moments)", label: moments == 1 ? "moment" : "moments")) + } + if let conversations = stats.totalConversations, conversations > 0 { + chips.append( + Chip( + id: "conversations", symbol: "bubble.left.and.bubble.right", value: "\(conversations)", + label: conversations == 1 ? "conversation" : "conversations")) + } + if let minutes = stats.totalDurationMinutes, minutes > 0 { + chips.append(Chip(id: "listening", symbol: "waveform", value: duration(minutes), label: "listening")) + } + if let memories = stats.memoriesCreated, memories > 0 { + chips.append( + Chip(id: "memories", symbol: "sparkles", value: "\(memories)", label: memories == 1 ? "memory" : "memories")) + } + let tasks = stats.actionItemsCreated ?? stats.actionItemsCount + if let tasks, tasks > 0 { + chips.append( + Chip(id: "tasks", symbol: "checkmark.circle", value: "\(tasks)", label: tasks == 1 ? "task" : "tasks")) + } + return chips + } + + nonisolated static func duration(_ minutes: Int) -> String { + let hours = minutes / 60 + let rest = minutes % 60 + if hours == 0 { return "\(rest)m" } + if rest == 0 { return "\(hours)h" } + return "\(hours)h \(rest)m" + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift deleted file mode 100644 index 31dabba0fc3..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift +++ /dev/null @@ -1,357 +0,0 @@ -import Foundation - -// MARK: - "Here's what it already knows to do" rows - -/// One row in the Home hub's knows-list: a concrete task, a proactive insight, -/// or a suggested question to ask. -enum HomeKnowsRowKind: Equatable { - case task(id: String) - case insight(id: String) - case question - - /// Bounded analytics dimension — never carries the row's text. - var analyticsKind: String { - switch self { - case .task: return "task" - case .insight: return "insight" - case .question: return "question" - } - } -} - -struct HomeKnowsRow: Identifiable, Equatable { - let kind: HomeKnowsRowKind - let text: String - /// Stable identity in the impression ledger. Question rows hash their text. - var ledgerKey: String = "" - /// Hash of the underlying object; a dismissed row returns only when it moves. - var contentHash: String = "" - /// How many times this row had already been shown before this composition. - var showsBefore: Int = 0 - - var id: String { - switch kind { - case .task(let id): return "task-\(id)" - case .insight(let id): return "insight-\(id)" - case .question: return "question-\(text)" - } - } -} - -/// The four typed slots. Fixed so the list stays diverse instead of collapsing -/// into all-tasks when one source is thin. -enum HomeKnowsSlot: String, Equatable, Sendable, CaseIterable { - case pressingTask = "pressing_task" - case tip - case secondTask = "second_task" - case ask -} - -/// A slot that stayed empty rather than repeating a row the reader has already -/// seen. The list is allowed to be shorter than four. -struct HomeKnowsEmptySlot: Equatable { - let slot: HomeKnowsSlot - let reason: HomeKnowsRotationReason -} - -struct HomeKnowsComposition: Equatable { - static let empty = HomeKnowsComposition(rows: [], emptySlots: [], canRotate: false) - - let rows: [HomeKnowsRow] - let emptySlots: [HomeKnowsEmptySlot] - /// True when more candidates qualify than the list shows, so the in-visit - /// rotation cycles to genuinely different rows instead of the same set. - let canRotate: Bool -} - -struct HomeKnowsTaskCandidate: Equatable { - let id: String - let text: String - var dueAt: Date? - var updatedAt: Date? - /// False once the task is completed, retired, or deleted. - var isActive: Bool - - init(id: String, text: String, dueAt: Date? = nil, updatedAt: Date? = nil, isActive: Bool = true) { - self.id = id - self.text = text - self.dueAt = dueAt - self.updatedAt = updatedAt - self.isActive = isActive - } -} - -struct HomeKnowsInsightCandidate: Equatable { - let id: String - let text: String - var updatedAt: Date? - - init(id: String, text: String, updatedAt: Date? = nil) { - self.id = id - self.text = text - self.updatedAt = updatedAt - } -} - -/// Builds the hub rows under the greeting as a deliberately DIVERSE set — one -/// pressing task, a tip (a real insight if there is one, otherwise a composed, -/// high-agency nudge you can hand Omi), a second task, and a prefilled ask. -/// Fixed typed slots keep it from collapsing into an all-tasks list when one -/// source (usually insights) is thin. -/// -/// Every slot is gated by `HomeKnowsRotationPolicy` against the impression -/// ledger, so a thin source produces a *shorter* list rather than the same four -/// rows on every visit. An empty slot is the intended outcome, not a bug. -enum HomeKnowsListComposer { - static let maxRows = 4 - - /// Open tasks the reader has not dismissed — the count the greeting's daily - /// brief and composed tip are phrased around. - static func openTaskCount( - _ tasks: [HomeKnowsTaskCandidate], - ledger: HomeKnowsImpressionLedger = .empty - ) -> Int { - tasks.filter { candidate in - guard candidate.isActive else { return false } - return ledger.entry(HomeKnowsRotationPolicy.taskKey(candidate.id))?.dismissedAt == nil - }.count - } - - static func compose( - tasks: [HomeKnowsTaskCandidate], - insights: [HomeKnowsInsightCandidate], - tip: String? = nil, - questions: [String], - ledger: HomeKnowsImpressionLedger = .empty, - now: Date = Date(), - calendar: Calendar = .current, - rotation: Int = 0 - ) -> HomeKnowsComposition { - // Task ids repeat across the overdue/today/no-due-date buckets the hub - // concatenates; a duplicate would collide as a ForEach ID. - var seenTaskIDs = Set<String>() - let taskCandidates = - tasks - .filter { candidate in - !candidate.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && seenTaskIDs.insert(candidate.id).inserted - } - .enumerated() - .map { index, candidate in - Scored( - element: candidate, - facts: HomeKnowsCandidateFacts( - key: HomeKnowsRotationPolicy.taskKey(candidate.id), - contentHash: HomeKnowsRotationPolicy.contentHash( - text: candidate.text, updatedAt: candidate.updatedAt), - updatedAt: candidate.updatedAt, - dueAt: candidate.dueAt, - isActive: candidate.isActive), - order: index) - } - - let insightCandidates = - insights - .filter { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .enumerated() - .map { index, candidate in - Scored( - element: candidate, - facts: HomeKnowsCandidateFacts( - key: HomeKnowsRotationPolicy.insightKey(candidate.id), - contentHash: HomeKnowsRotationPolicy.contentHash( - text: candidate.text, updatedAt: candidate.updatedAt), - updatedAt: candidate.updatedAt), - order: index) - } - - let trimmedTip = tip?.trimmingCharacters(in: .whitespacesAndNewlines) - let cleanTip = (trimmedTip?.isEmpty == false) ? trimmedTip : nil - - // Question rows are identified by their text, so a repeated suggestion - // would collide as a ForEach ID — keep only the first occurrence. - var seenQuestions = Set<String>() - let questionCandidates = - questions - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && seenQuestions.insert($0).inserted } - .enumerated() - .map { index, text in Scored(element: text, facts: questionFacts(text), order: index) } - - let taskPool = pool(taskCandidates, ledger: ledger, now: now, calendar: calendar) - let insightPool = pool(insightCandidates, ledger: ledger, now: now, calendar: calendar) - let questionPool = pool(questionCandidates, ledger: ledger, now: now, calendar: calendar) - let tipPool = cleanTip.map { text in - pool( - [Scored(element: text, facts: questionFacts(text), order: 0)], - ledger: ledger, now: now, calendar: calendar) - } - - // Rotate each pool so the hub cycles through the qualifying candidates while - // the diverse task · tip · task · ask structure below stays fixed. - let freshTasks = rotated(taskPool.eligible, by: rotation) - let freshInsights = rotated(insightPool.eligible, by: rotation) - let freshQuestions = rotated(questionPool.eligible, by: rotation) - // The ask never duplicates the composed tip. - let ask = freshQuestions.first { $0.element != cleanTip } - - var rows: [HomeKnowsRow] = [] - var emptySlots: [HomeKnowsEmptySlot] = [] - - func fill( - _ slot: HomeKnowsSlot, with row: HomeKnowsRow?, otherwise reason: @autoclosure () -> HomeKnowsRotationReason - ) { - if let row { - rows.append(row) - } else { - emptySlots.append(HomeKnowsEmptySlot(slot: slot, reason: reason())) - } - } - - // 1) The single most pressing task. - fill( - .pressingTask, - with: freshTasks.first.map { - row(kind: .task(id: $0.element.id), text: $0.element.text, facts: $0.facts, ledger: ledger) - }, - otherwise: taskPool.emptyReason) - - // 2) A tip — a real server insight, else a composed nudge that prefills chat. - if let insight = freshInsights.first { - rows.append( - row(kind: .insight(id: insight.element.id), text: insight.element.text, facts: insight.facts, ledger: ledger)) - } else if let tipPool, let tipRow = tipPool.eligible.first { - rows.append(row(kind: .question, text: tipRow.element, facts: tipRow.facts, ledger: ledger)) - } else { - // Report the insight source's reason when there was one; a suppressed - // composed tip is why the slot is empty only when insights never existed. - let reason = insightCandidates.isEmpty ? (tipPool?.emptyReason ?? .noCandidate) : insightPool.emptyReason - emptySlots.append(HomeKnowsEmptySlot(slot: .tip, reason: reason)) - } - - // 3) A second concrete task — but only if the prefilled ask can still follow. - // Yielding the slot to the ask is a layout choice, not a rotation outcome, - // so it is not reported as an empty slot. - if freshTasks.count > 1 { - if ask == nil || rows.count < maxRows - 1 { - let task = freshTasks[1] - rows.append( - row(kind: .task(id: task.element.id), text: task.element.text, facts: task.facts, ledger: ledger)) - } - } else { - emptySlots.append(HomeKnowsEmptySlot(slot: .secondTask, reason: taskPool.emptyReason)) - } - - // 4) A prefilled ask, so there's always a distinct thing to hand Omi. - if let ask { - if rows.count < maxRows { - rows.append(row(kind: .question, text: ask.element, facts: ask.facts, ledger: ledger)) - } - } else { - emptySlots.append(HomeKnowsEmptySlot(slot: .ask, reason: questionPool.emptyReason)) - } - - return HomeKnowsComposition( - rows: Array(rows.prefix(maxRows)), - emptySlots: emptySlots, - canRotate: canRotate( - taskCount: taskPool.eligible.count, - insightCount: insightPool.eligible.count, - questionCount: questionPool.eligible.count)) - } - - /// How many candidates must qualify beyond what's shown before the hub starts - /// rotating — otherwise the same rows would "rotate" back onto themselves. - static func canRotate(taskCount: Int, insightCount: Int, questionCount: Int) -> Bool { - taskCount > 2 || insightCount > 1 || questionCount > 1 - } - - // MARK: Internals - - /// A candidate paired with the facts the rotation rules read, and its - /// position in the caller's own priority order. - private struct Scored<Element> { - let element: Element - let facts: HomeKnowsCandidateFacts - let order: Int - } - - /// The qualifying candidates for one slot, plus why the rest were held back. - private struct Pool<Element> { - let eligible: [Scored<Element>] - let emptyReason: HomeKnowsRotationReason - } - - private static func questionFacts(_ text: String) -> HomeKnowsCandidateFacts { - HomeKnowsCandidateFacts( - key: HomeKnowsRotationPolicy.questionKey(text), - contentHash: HomeKnowsRotationPolicy.contentHash(text: text)) - } - - private static func row( - kind: HomeKnowsRowKind, - text: String, - facts: HomeKnowsCandidateFacts, - ledger: HomeKnowsImpressionLedger - ) -> HomeKnowsRow { - HomeKnowsRow( - kind: kind, - text: text, - ledgerKey: facts.key, - contentHash: facts.contentHash, - showsBefore: ledger.entry(facts.key)?.shows ?? 0) - } - - /// Applies the rotation rules, then orders what survives by freshness: - /// never-shown first, then fewest shows, then most recent underlying update. - /// - /// Same-day repeats are relaxed only when the strict pass leaves the slot with - /// nothing at all — and even then the policy still requires a prior open. - private static func pool<Element>( - _ candidates: [Scored<Element>], - ledger: HomeKnowsImpressionLedger, - now: Date, - calendar: Calendar - ) -> Pool<Element> { - guard !candidates.isEmpty else { return Pool(eligible: [], emptyReason: .noCandidate) } - - func pass(allowSameDayRepeat: Bool) -> ([Scored<Element>], [HomeKnowsRotationReason]) { - var eligible: [Scored<Element>] = [] - var reasons: [HomeKnowsRotationReason] = [] - for candidate in candidates { - if let reason = HomeKnowsRotationPolicy.suppression( - facts: candidate.facts, - entry: ledger.entry(candidate.facts.key), - now: now, - calendar: calendar, - allowSameDayRepeat: allowSameDayRepeat) - { - reasons.append(reason) - } else { - eligible.append(candidate) - } - } - return (eligible, reasons) - } - - var (eligible, reasons) = pass(allowSameDayRepeat: false) - if eligible.isEmpty { - (eligible, reasons) = pass(allowSameDayRepeat: true) - } - // `sorted(by:)` is not stable, so the caller's order is the explicit final - // tie-break rather than something the sort happens to preserve. - let ordered = eligible.sorted { lhs, rhs in - let lhsRank = HomeKnowsRotationPolicy.freshnessRank(lhs.facts, ledger: ledger) - let rhsRank = HomeKnowsRotationPolicy.freshnessRank(rhs.facts, ledger: ledger) - if lhsRank != rhsRank { return lhsRank < rhsRank } - return lhs.order < rhs.order - } - return Pool(eligible: ordered, emptyReason: HomeKnowsRotationPolicy.dominantReason(reasons)) - } - - private static func rotated<T>(_ arr: [T], by rotation: Int) -> [T] { - guard arr.count > 1 else { return arr } - let k = ((rotation % arr.count) + arr.count) % arr.count - return Array(arr[k...] + arr[..<k]) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsImpressionLedger.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsImpressionLedger.swift deleted file mode 100644 index 32766ea2324..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsImpressionLedger.swift +++ /dev/null @@ -1,307 +0,0 @@ -import CryptoKit -import Foundation - -// MARK: - What the knows-list already showed - -/// One row's history in the Home knows-list. -/// -/// The composer used to have no memory at all, so a thin source (four open -/// commitments, one stale insight) re-rendered the same four rows on every -/// visit — the owner's 14-day sample repeated "meet with <person>" nine times -/// and "improve meeting notes quality" eight. This is the memory. -struct HomeKnowsImpression: Codable, Equatable { - var shows: Int = 0 - var firstShownAt: Date? - var lastShownAt: Date? - var lastOpenedAt: Date? - var dismissedAt: Date? - /// Hash of the underlying object the last time this entry was written. - /// A dismissed or capped row returns only when this changes. - var contentHash: String = "" -} - -struct HomeKnowsImpressionLedger: Codable, Equatable { - static let empty = HomeKnowsImpressionLedger() - - var entries: [String: HomeKnowsImpression] = [:] - - func entry(_ key: String) -> HomeKnowsImpression? { entries[key] } -} - -/// The identity and freshness facts the rotation rules need, independent of -/// which source a candidate came from. -struct HomeKnowsCandidateFacts: Equatable { - let key: String - let contentHash: String - /// When the underlying object last changed. `nil` sorts last on freshness. - let updatedAt: Date? - /// Due date for task rows; `nil` for everything else. - let dueAt: Date? - /// False once the underlying task is completed, retired, or deleted. - let isActive: Bool - - init( - key: String, - contentHash: String, - updatedAt: Date? = nil, - dueAt: Date? = nil, - isActive: Bool = true - ) { - self.key = key - self.contentHash = contentHash - self.updatedAt = updatedAt - self.dueAt = dueAt - self.isActive = isActive - } -} - -/// Why a candidate did not get its slot. Bounded set — it is a PostHog property. -enum HomeKnowsRotationReason: String, Equatable, Sendable, CaseIterable { - /// The reader dismissed it and the underlying object has not changed since. - case dismissed - /// Shown the cap number of times without ever being opened. - case showCap = "show_cap" - /// Already shown today, and never opened, so it does not repeat. - case sameDay = "same_day" - /// Task due date is far enough past that surfacing it is noise. - case staleDueDate = "stale_due_date" - /// Underlying task was completed or deleted. - case inactive - /// The source had nothing to offer this slot at all. - case noCandidate = "no_candidate" -} - -// MARK: - Rules - -/// Deterministic, clock-injected rotation rules. Pure by construction so the -/// behaviour is unit-testable without UserDefaults or a running app. -enum HomeKnowsRotationPolicy { - /// Shows without an open before a row rotates out. - static let showCapCount = 3 - /// How long a capped row stays out. - static let showCapCooldown: TimeInterval = 7 * 24 * 60 * 60 - /// How far past due a task may be before it stops being surfaced. - static let stalePastDueGrace: TimeInterval = 14 * 24 * 60 * 60 - - /// A stable content hash for a row's underlying object. A dismissed row - /// returns only when this changes, so it must move when the object does. - static func contentHash(text: String, updatedAt: Date? = nil) -> String { - let stamp = updatedAt.map { String(Int($0.timeIntervalSince1970)) } ?? "-" - return digest("\(text)|\(stamp)") - } - - /// Ledger key for a free-text row (a suggested question or composed tip). - /// Hashed rather than raw so the persisted ledger is not a copy of the - /// reader's suggestions. - static func questionKey(_ text: String) -> String { "question:\(digest(text))" } - static func taskKey(_ id: String) -> String { "task:\(id)" } - static func insightKey(_ id: String) -> String { "insight:\(id)" } - - private static func digest(_ value: String) -> String { - let hash = SHA256.hash(data: Data(value.utf8)) - return String(hash.compactMap { String(format: "%02x", $0) }.joined().prefix(16)) - } - - /// Why this candidate must not take a slot right now, or `nil` if it may. - /// - /// - Parameter allowSameDayRepeat: set only when the slot has no other - /// qualifying candidate. Even then a same-day repeat needs a prior open. - static func suppression( - facts: HomeKnowsCandidateFacts, - entry: HomeKnowsImpression?, - now: Date, - calendar: Calendar, - allowSameDayRepeat: Bool - ) -> HomeKnowsRotationReason? { - guard facts.isActive else { return .inactive } - if let dueAt = facts.dueAt, now.timeIntervalSince(dueAt) > stalePastDueGrace { - return .staleDueDate - } - guard let entry else { return nil } - // A changed underlying object is new information: it clears a dismissal and - // resets the show cap. That is the only way a dismissed row ever returns. - guard entry.contentHash == facts.contentHash else { return nil } - if entry.dismissedAt != nil { return .dismissed } - - if entry.lastOpenedAt == nil, entry.shows >= showCapCount { - let lastShownAt = entry.lastShownAt ?? .distantPast - if now.timeIntervalSince(lastShownAt) < showCapCooldown { return .showCap } - } - - if let lastShownAt = entry.lastShownAt, calendar.isDate(lastShownAt, inSameDayAs: now) { - guard allowSameDayRepeat, entry.lastOpenedAt != nil else { return .sameDay } - } - return nil - } - - /// Freshness order inside one slot: never-shown first, then fewest shows, - /// then most recently updated. - /// - /// Deliberately not tie-broken on the row key: candidates arrive in the - /// caller's own priority order (the most pressing task, the best-ranked - /// suggested question), and hashing that order away would silently reorder - /// equally-fresh rows. Callers break remaining ties on the source index. - static func freshnessRank( - _ facts: HomeKnowsCandidateFacts, - ledger: HomeKnowsImpressionLedger - ) -> (Int, Int, Double) { - let shows = ledger.entry(facts.key)?.shows ?? 0 - let updated = facts.updatedAt?.timeIntervalSince1970 ?? 0 - return (shows == 0 ? 0 : 1, shows, -updated) - } - - /// The one reason worth reporting when a whole slot came up empty. Fixed - /// priority so the telemetry is deterministic rather than dictionary-ordered. - static func dominantReason(_ reasons: [HomeKnowsRotationReason]) -> HomeKnowsRotationReason { - let priority: [HomeKnowsRotationReason] = [ - .dismissed, .showCap, .sameDay, .staleDueDate, .inactive, .noCandidate, - ] - return priority.first { reasons.contains($0) } ?? .noCandidate - } -} - -// MARK: - Persistence - -@MainActor -protocol HomeKnowsImpressionPersisting: AnyObject { - func load() -> HomeKnowsImpressionLedger - func save(_ ledger: HomeKnowsImpressionLedger) -} - -/// Owner-scoped so one account's dismissals never silence the knows-list for -/// another account on the same Mac (the #9821 account-switch-bleed class). -@MainActor -final class HomeKnowsImpressionDefaults: HomeKnowsImpressionPersisting { - /// Entries with no activity inside this window are dropped on load, so the - /// ledger cannot grow without bound across months of tasks. - static let retention: TimeInterval = 90 * 24 * 60 * 60 - - private let defaults: UserDefaults - private let fixedOwnerID: String? - private let now: () -> Date - - init(defaults: UserDefaults = .standard, ownerID: String? = nil, now: @escaping () -> Date = Date.init) { - self.defaults = defaults - fixedOwnerID = ownerID - self.now = now - } - - private var key: ScopedDefaultsKey { - let dynamicOwner = - defaults === UserDefaults.standard - ? RuntimeOwnerIdentity.currentOwnerId() - : defaults.string(forKey: .authUserId) - return .homeKnowsImpressions(ownerID: fixedOwnerID ?? dynamicOwner ?? "signed-out") - } - - func load() -> HomeKnowsImpressionLedger { - guard let data = defaults.data(forKey: key), - let decoded = try? JSONDecoder().decode(HomeKnowsImpressionLedger.self, from: data) - else { return .empty } - let cutoff = now().addingTimeInterval(-Self.retention) - var pruned = decoded - pruned.entries = decoded.entries.filter { _, impression in - let touched = [impression.lastShownAt, impression.dismissedAt, impression.lastOpenedAt] - .compactMap { $0 } - .max() - return (touched ?? .distantPast) >= cutoff - } - return pruned - } - - func save(_ ledger: HomeKnowsImpressionLedger) { - defaults.set(try? JSONEncoder().encode(ledger), forKey: key) - } -} - -// MARK: - Store (single mutation owner) - -/// The only thing that writes the knows-list ledger. -/// -/// Views read a snapshot taken when the list appeared and hand every show, -/// open, and dismiss back here; nothing else mutates impression state. -@MainActor -final class HomeKnowsImpressionStore { - static let shared = HomeKnowsImpressionStore() - - private let persistence: any HomeKnowsImpressionPersisting - private let now: () -> Date - /// Row keys and slot names already reported during the current visit. One - /// visit is one impression: the in-visit rotation timer re-renders the same - /// row every few seconds and must not burn through the show cap. - private var reportedThisVisit: Set<String> = [] - - /// `persistence` defaults to owner-scoped `UserDefaults`. It is built inside - /// the initializer rather than as a default argument because default argument - /// expressions are evaluated outside this type's actor. - init( - persistence: (any HomeKnowsImpressionPersisting)? = nil, - now: @escaping () -> Date = Date.init - ) { - self.persistence = persistence ?? HomeKnowsImpressionDefaults() - self.now = now - } - - /// Reads through to storage so an account switch cannot be served a cached - /// ledger from the previous owner. - func snapshot() -> HomeKnowsImpressionLedger { persistence.load() } - - /// Starts a new visit to the knows-list. Resets in-visit de-duplication. - func beginVisit() { reportedThisVisit.removeAll() } - - /// Records a row as shown. Returns the updated impression, or `nil` when this - /// row was already recorded during the current visit. - @discardableResult - func recordShown(key: String, contentHash: String) -> HomeKnowsImpression? { - guard reportedThisVisit.insert(key).inserted else { return nil } - return mutate(key: key) { impression in - let contentChanged = impression.contentHash != contentHash - let cooledDown = - impression.lastOpenedAt == nil - && impression.shows >= HomeKnowsRotationPolicy.showCapCount - && self.now().timeIntervalSince(impression.lastShownAt ?? .distantPast) - >= HomeKnowsRotationPolicy.showCapCooldown - if contentChanged || cooledDown { - impression.shows = 0 - impression.firstShownAt = nil - impression.dismissedAt = nil - } - impression.shows += 1 - impression.firstShownAt = impression.firstShownAt ?? self.now() - impression.lastShownAt = self.now() - impression.contentHash = contentHash - } - } - - @discardableResult - func recordOpened(key: String, contentHash: String) -> HomeKnowsImpression { - mutate(key: key) { impression in - impression.lastOpenedAt = self.now() - impression.dismissedAt = nil - impression.contentHash = contentHash - } - } - - @discardableResult - func recordDismissed(key: String, contentHash: String) -> HomeKnowsImpression { - mutate(key: key) { impression in - impression.dismissedAt = self.now() - impression.contentHash = contentHash - } - } - - /// True the first time this visit that an empty slot is worth reporting. - func shouldReportEmptySlot(_ slot: String) -> Bool { - reportedThisVisit.insert("slot:\(slot)").inserted - } - - @discardableResult - private func mutate(key: String, _ body: (inout HomeKnowsImpression) -> Void) -> HomeKnowsImpression { - var ledger = persistence.load() - var impression = ledger.entries[key] ?? HomeKnowsImpression() - body(&impression) - ledger.entries[key] = impression - persistence.save(ledger) - return impression - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift new file mode 100644 index 00000000000..ecb441c2e9f --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Where the app remembers "open this exact task next". +/// +/// This file used to hold `DashboardIntelligenceStore` and the whole client +/// protocol it fetched through. Nothing rendered that store once `DashboardPage` +/// was deleted (#12598) — its recommendations had no surface — so it went with +/// the page. This handoff stayed: `QueryShellHome` and the chat-first task card +/// both hand the Tasks page an exact record rather than a tab index. +@MainActor +final class TaskNavigationRequestStore { + static let shared = TaskNavigationRequestStore() + enum Target: Equatable { + case task(String) + case candidate(String) + } + + private(set) var pendingTarget: Target? + private(set) var pendingTask: TaskActionItem? + private(set) var pendingCandidate: OmiAPI.CandidateRecord? + private var runtimeOwnerObserver: NSObjectProtocol? + + init() { + runtimeOwnerObserver = NotificationCenter.default.addObserver( + forName: .runtimeOwnerDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.clear() } + } + } + + func request(task: TaskActionItem) { + pendingTarget = .task(task.id) + pendingTask = task + pendingCandidate = nil + } + + func request(candidate: OmiAPI.CandidateRecord) { + pendingTarget = .candidate(candidate.candidateId) + pendingTask = nil + pendingCandidate = candidate + } + + func peek() -> Target? { + pendingTarget + } + + func consumeIfAvailable(taskIDs: Set<String>, candidateIDs: Set<String>) -> Target? { + guard let target = pendingTarget else { return nil } + let isAvailable: Bool + switch target { + case .task(let id): isAvailable = taskIDs.contains(id) + case .candidate(let id): isAvailable = candidateIDs.contains(id) + } + guard isAvailable else { return nil } + clear() + return target + } + + private func clear() { + pendingTarget = nil + pendingTask = nil + pendingCandidate = nil + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift deleted file mode 100644 index e33bf9f48d3..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift +++ /dev/null @@ -1,369 +0,0 @@ -import OmiTheme -import SwiftUI - -// Recommendation ("what matters now") surfacing moved into the Home hub's -// knows-list rows in DashboardPage; this file keeps the goals surfaces. - -struct FocusedGoalsSection: View { - @ObservedObject var store: DashboardIntelligenceStore - let onOpenGoal: (String) async -> Void - let onShowAll: () -> Void - - var body: some View { - if !store.focusedGoals.isEmpty { - HStack(spacing: 8) { - Text("Focused goals") - .scaledFont(size: 11, weight: .semibold) - .foregroundColor(Ink.secondary) - ForEach(store.focusedGoals.prefix(5), id: \.goalId) { goal in - Button { - Task { await onOpenGoal(goal.goalId) } - } label: { - Text(goal.title) - .scaledFont(size: 10, weight: .medium) - .lineLimit(1) - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Capsule().fill(Ink.rowFill.opacity(0.8))) - } - .buttonStyle(.plain) - .accessibilityIdentifier("focused-goal-\(goal.goalId)") - } - Spacer() - Button("All goals", action: onShowAll) - .buttonStyle(.plain) - .scaledFont(size: 10, weight: .medium) - .foregroundColor(Ink.secondary) - } - .accessibilityIdentifier("focused-goals") - } else if store.accountGeneration != nil { - HStack { - Text("No focused goals") - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - Spacer() - Button(store.goals.isEmpty ? "Add goal" : "Choose focus", action: onShowAll) - .buttonStyle(.plain) - .scaledFont(size: 10, weight: .medium) - } - } - } -} - -struct AllGoalsSheet: View { - @ObservedObject var store: DashboardIntelligenceStore - let onOpenGoal: (String) async -> Void - let onDismiss: () -> Void - - @State private var showHistory = false - @State private var focusTarget: GoalFocusTarget? - @State private var replacementGoalID: String = "" - @State private var showingCreateGoal = false - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("All goals") - .scaledFont(size: 20, weight: .semibold) - Spacer() - Picker("View", selection: $showHistory) { - Text("Current").tag(false) - Text("History").tag(true) - } - .pickerStyle(.segmented) - .frame(width: 180) - Button("Add goal") { showingCreateGoal = true } - .buttonStyle(.bordered) - Button("Done", action: onDismiss) - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - } - - ScrollView { - LazyVStack(spacing: 8) { - ForEach(displayedGoals, id: \.goalId) { goal in - goalRow(goal) - } - } - } - - if let error = store.error { - Text(error) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - } - .padding(20) - .frame(width: 620, height: 540) - .sheet(item: $focusTarget) { target in - focusReplacementSheet(target.goal) - } - .sheet(isPresented: $showingCreateGoal) { - CanonicalGoalCreateSheet( - error: store.error, - onSave: { title, outcome, why, criteria, idempotencyKey in - if await store.createGoal( - title: title, - desiredOutcome: outcome, - whyItMatters: why, - successCriteria: criteria, - idempotencyKey: idempotencyKey - ) { - showingCreateGoal = false - } - }, - onDismiss: { showingCreateGoal = false } - ) - } - } - - private var displayedGoals: [OmiAPI.GoalResponse] { - showHistory ? store.endedGoals : store.currentGoals - } - - private func goalRow(_ goal: OmiAPI.GoalResponse) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(goal.title) - .scaledFont(size: 13, weight: .semibold) - .foregroundColor(Ink.primary) - Text(goal.desiredOutcome) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - .lineLimit(1) - } - Spacer() - Text(goal.status.rawValue.capitalized) - .scaledFont(size: 9) - .foregroundColor(Ink.secondary) - - Button("Open") { Task { await onOpenGoal(goal.goalId) } } - .buttonStyle(.bordered) - - if !showHistory { - Button(goal.status == .focused ? "Unfocus" : "Focus") { - Task { - if goal.status == .focused { - await store.unfocus(goalID: goal.goalId) - } else { - let focused = await store.focus(goalID: goal.goalId, replacing: nil) - if !focused, store.focusReplacementGoalID == goal.goalId { - replacementGoalID = store.focusedGoals.first?.goalId ?? "" - focusTarget = GoalFocusTarget(goal: goal) - } - } - } - } - .buttonStyle(.bordered) - - Menu("More") { - Button("Pause") { Task { await store.transition(goalID: goal.goalId, status: .paused) } } - Button("Mark achieved") { Task { await store.transition(goalID: goal.goalId, status: .achieved) } } - Button("Abandon") { Task { await store.transition(goalID: goal.goalId, status: .abandoned) } } - } - .menuStyle(.borderlessButton) - .frame(width: 55) - } - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 9).fill(Ink.rowFill.opacity(0.7))) - } - - private struct GoalFocusTarget: Identifiable { - let goal: OmiAPI.GoalResponse - var id: String { goal.goalId } - } - - private func focusReplacementSheet(_ goal: OmiAPI.GoalResponse) -> some View { - VStack(alignment: .leading, spacing: 14) { - Text("Replace a focused goal") - .scaledFont(size: 16, weight: .semibold) - Text("Your focus set is full. Nothing is archived; the replaced goal moves to All goals.") - .scaledFont(size: 11) - .foregroundColor(Ink.secondary) - Picker("Replace", selection: $replacementGoalID) { - ForEach(store.focusedGoals, id: \.goalId) { focused in - Text(focused.title).tag(focused.goalId) - } - } - HStack { - Button("Cancel") { focusTarget = nil } - Spacer() - Button("Replace focus") { - Task { - if await store.focus(goalID: goal.goalId, replacing: replacementGoalID) { - focusTarget = nil - } - } - } - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - } - } - .padding(20) - .frame(width: 420) - } -} - -private struct CanonicalGoalCreateSheet: View { - let error: String? - let onSave: (String, String, String?, [String], String) async -> Void - let onDismiss: () -> Void - - @State private var title = "" - @State private var desiredOutcome = "" - @State private var whyItMatters = "" - @State private var successCriteria = "" - @State private var createGoalOccurrenceID = UUID().uuidString.lowercased() - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Add goal") - .scaledFont(size: 18, weight: .semibold) - TextField("Short name", text: $title) - .textFieldStyle(.roundedBorder) - TextField("Desired outcome", text: $desiredOutcome, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...4) - TextField("Why it matters (optional)", text: $whyItMatters, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...4) - TextField("Success criteria, one per line", text: $successCriteria, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...5) - if let error, !error.isEmpty { - Text(error) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - HStack { - Button("Cancel", action: onDismiss) - Spacer() - Button("Add goal") { - let criteria = successCriteria.split(separator: "\n").map { - String($0).trimmingCharacters(in: .whitespacesAndNewlines) - }.filter { !$0.isEmpty } - Task { - await onSave( - title.trimmingCharacters(in: .whitespacesAndNewlines), - desiredOutcome.trimmingCharacters(in: .whitespacesAndNewlines), - whyItMatters.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, - criteria, - createGoalOccurrenceID - ) - } - } - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - .disabled( - title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || desiredOutcome.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(20) - .frame(width: 460) - } -} - -extension String { - fileprivate var nilIfEmpty: String? { isEmpty ? nil : self } -} - -struct CanonicalGoalDetailSheet: View { - let detail: OmiAPI.GoalDetailProjection - let error: String? - let onResumeThread: (String) async -> Void - let onStartWork: () async -> Void - let onDismiss: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(detail.goal.title) - .scaledFont(size: 20, weight: .semibold) - Text(detail.goal.desiredOutcome) - .scaledFont(size: 12) - .foregroundColor(Ink.secondary) - } - Spacer() - Button("Done", action: onDismiss) - } - - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if let why = detail.goal.whyItMatters, !why.isEmpty { - goalDetailBlock(title: "Why it matters", text: why) - } - if let criteria = detail.goal.successCriteria, !criteria.isEmpty { - goalDetailBlock(title: "Success looks like", text: criteria.joined(separator: " • ")) - } - if let metric = detail.goal.metric { - goalDetailBlock( - title: "Progress", - text: "\(metric.current.formatted()) / \(metric.target.formatted()) \(metric.unit ?? "")" - ) - } - - if !detail.activeThreads.isEmpty { - Text("Active work") - .scaledFont(size: 12, weight: .semibold) - ForEach(detail.activeThreads, id: \.workstreamId) { work in - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(work.title) - .scaledFont(size: 12, weight: .semibold) - Text(work.currentStateSummary ?? work.objective) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - .lineLimit(2) - } - Spacer() - Button("Continue") { Task { await onResumeThread(work.workstreamId) } } - .buttonStyle(.bordered) - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 9).fill(Ink.rowFill.opacity(0.7))) - } - } - - if !detail.progressEvents.isEmpty { - Text("Meaningful progress") - .scaledFont(size: 12, weight: .semibold) - ForEach(detail.progressEvents, id: \.eventId) { event in - HStack(alignment: .top, spacing: 8) { - Circle().fill(Ink.secondary).frame(width: 5, height: 5).padding(.top, 5) - Text(event.summary) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - } - } - } - } - - Button("Work on this with Omi") { Task { await onStartWork() } } - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - .accessibilityIdentifier("goal-work-with-omi-\(detail.goal.goalId)") - if let error, !error.isEmpty { - Text(error) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - } - .padding(20) - .frame(width: 620, height: 600) - } - - private func goalDetailBlock(title: String, text: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title).scaledFont(size: 11, weight: .semibold) - Text(text).scaledFont(size: 10).foregroundColor(Ink.secondary) - } - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 4202095d81a..96e514c3b16 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -19,13 +19,6 @@ enum PersistedCaptureLaunchPolicy { } } -enum DesktopHomeEscapeNavigation { - static func shouldNavigateHome(selectedIndex: Int, usesLegacyHomeDesign: Bool) -> Bool { - guard !usesLegacyHomeDesign, let item = SidebarNavItem(rawValue: selectedIndex) else { return false } - return [.conversations, .memories, .tasks, .rewind].contains(item) - } -} - // MARK: - NSHostingView sizingOptions access /// Protocol to access sizingOptions on any NSHostingView<Content> regardless of the generic parameter. @@ -44,23 +37,20 @@ struct DesktopHomeView: View { @StateObject private var viewModelContainer = ViewModelContainer() /// The Chat-first shell owns typed navigation at the root, never through legacy /// sidebar indices. It persists only route/collapse state, not enrollment. - @StateObject private var chatFirstNavigation = ChatFirstShellNavigation() + /// The one navigation owner, shared with the auxiliary Chat surfaces (task + /// panel, floating/notch) so a content block tapped there routes *this* shell + /// rather than a second instance nothing renders. + @ObservedObject private var chatFirstNavigation = ChatFirstShellNavigation.shared @ObservedObject private var authState = AuthState.shared @ObservedObject private var apiKeyService = APIKeyService.shared @ObservedObject private var updatePolicyManager = DesktopUpdatePolicyManager.shared @ObservedObject private var accountCutoverControl = AccountCutoverControlManager.shared @ObservedObject private var automationPresentationCoordinator = DesktopAutomationPresentationCoordinator.shared - @State private var selectedIndex: Int = { - if OMIApp.launchMode == .rewind { return SidebarNavItem.rewind.rawValue } - return SidebarNavItem.dashboard.rawValue - }() - @State private var isSidebarCollapsed: Bool = true @AppStorage("currentTierLevel") private var currentTierLevel = 0 @AppStorage("onboardingStep") private var onboardingStep = 0 @AppStorage("onboardingFurthestStep") private var onboardingFurthestStep = 0 @AppStorage("onboardingJustCompleted") private var onboardingJustCompleted = false - @AppStorage("useLegacyHomeDesign") private var useLegacyHomeDesign = false @AppStorage(MemoryHubDestination.storageKey) private var memoryDestinationRawValue = MemoryHubDestination.memories.rawValue /// Reference instant for the top bar's "new since you were last here" counts — @@ -71,7 +61,6 @@ struct DesktopHomeView: View { @State private var selectedSettingsSection: SettingsContentView.SettingsSection = .general @State private var highlightedSettingId: String? = nil @State private var showTryAskingPopup = false - @State private var previousIndexBeforeSettings: Int = 0 @State private var logoPulse = false @State private var lastActivationRefresh = Date.distantPast @State private var didScheduleAgentVMProvisioning = false @@ -85,7 +74,9 @@ struct DesktopHomeView: View { @State private var initialFileIndexingBackfill = DelayedFileIndexingBackfillState() @State private var automationPresentationReadinessGate = DesktopAutomationPresentationReadinessGate() - @State private var chatFirstCapabilitySample = ChatFirstShellCapabilitySample() + /// Server-authoritative capability for the one shell. It never decides which + /// shell mounts — only whether the capability-gated kernel features engage. + @State private var chatFirstCapability = ChatFirstCapabilitySample() // Pre-loaded hero logo to avoid NSImage init crashes during SwiftUI body evaluation private static let heroLogoImage: NSImage? = { @@ -95,14 +86,6 @@ struct DesktopHomeView: View { return NSImage(data: data) }() - /// Whether we're currently viewing the settings page - private var isInSettings: Bool { - selectedIndex == SidebarNavItem.settings.rawValue - || selectedIndex == SidebarNavItem.permissions.rawValue - } - - private var homeOwnsItsPanels: Bool { !useLegacyHomeDesign } - private var shouldShowAuthEntryShell: Bool { authState.isRestoringAuth || authState.sessionPhase == .recoveryRequired || !authState.isSignedIn || !hasCompletedOnboardingAtAuthorityRead @@ -214,8 +197,9 @@ struct DesktopHomeView: View { .onAppear { log("DesktopHomeView: Showing mainContent (signed in and onboarded)") - // Only the legacy shell arms the first-use popup; chat-first renders starters in main chat. - if !usesChatFirstShell && PostOnboardingPromptSuggestions.shouldArmPopup() { + // The first-use popup is armed by the same guidance policy for every + // account now that there is one shell. + if PostOnboardingPromptSuggestions.shouldArmPopup() { showTryAskingPopup = true } updatePolicyManager.refresh(force: true) @@ -279,7 +263,7 @@ struct DesktopHomeView: View { log( "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) - chatFirstCapabilitySample.ownerDidChange(to: nil) + chatFirstCapability.ownerDidChange(to: nil) resetSessionScopedStartupWarmups() appState.conversationRepository.reset() appState.folders = [] @@ -327,12 +311,6 @@ struct DesktopHomeView: View { Group { if shouldShowAuthEntryShell { authEntryShell - } else if case .unresolved = chatFirstCapabilitySample.variant { - // Hold the legacy shell until the server-authoritative cohort settles. - ChatFirstCapabilityLoadingView() - .task(id: RuntimeOwnerIdentity.currentOwnerId() ?? "missing-owner") { - await resolveChatFirstCapabilityIfNeeded() - } } else { ZStack { // After onboarding completes, navigate to Tasks page @@ -345,6 +323,11 @@ struct DesktopHomeView: View { } } mainContentWithLifecycle + // The shell mounts immediately; the capability resolves alongside it and + // only decides whether capability-gated features engage. + .task(id: RuntimeOwnerIdentity.currentOwnerId() ?? "missing-owner") { + await resolveChatFirstCapabilityIfNeeded() + } if !viewModelContainer.isInitialLoadComplete { TransparentWindowStatusPanel { @@ -413,20 +396,16 @@ struct DesktopHomeView: View { // extrema and resets our pin, after which the window can be dragged small enough // to hide content. Re-pin on every live resize so AppKit keeps clamping the drag. installMinimumSizeGuardIfNeeded() - // Redirect if current page isn't visible at current tier - redirectIfPageHidden() reportAutomationState() handleAutomationPresentationReadinessChange(viewModelContainer.isInitialLoadComplete) } .onChange(of: currentTierLevel) { _, _ in - redirectIfPageHidden() reportAutomationState() } - .onChange(of: selectedIndex) { _, _ in - // Page nav recreates the content hosting view with default sizingOptions, which + .onChange(of: chatFirstNavigation.route) { _, _ in + // Route nav recreates the content hosting view with default sizingOptions, which // resets the window min — re-pin + re-disable to hold the minimum. enforceMainWindowMinimumSize() - reportAutomationState() } .onChange(of: automationPresentationCoordinator.activeCommand?.generation) { _, _ in guard @@ -443,12 +422,9 @@ struct DesktopHomeView: View { .onChange(of: authState.isSignedIn) { _, _ in reportAutomationState() } .onChange(of: authState.isRestoringAuth) { _, _ in reportAutomationState() } .onChange(of: appState.hasCompletedOnboarding) { _, _ in reportAutomationState() } - .onChange(of: chatFirstCapabilitySample.variant) { _, _ in - consumePendingMainChatRequestForChatFirstShell() - } .onReceive(NotificationCenter.default.publisher(for: .runtimeOwnerDidChange)) { _ in reconcileOnboardingCompletionOwner() - chatFirstCapabilitySample.ownerDidChange(to: RuntimeOwnerIdentity.currentOwnerId()) + chatFirstCapability.ownerDidChange(to: RuntimeOwnerIdentity.currentOwnerId()) // The provider's owner-bound gate rejects the previous sample for this // owner; no replacement sample is persisted or inferred locally. reportAutomationState() @@ -471,19 +447,12 @@ struct DesktopHomeView: View { } .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in // The global shortcut / notch "Ask Omi" opens the continuous chat, which - // lives on the chat-first home. DashboardPage focuses the input when it's - // already mounted; if we're on another tab, switch home first and re-emit - // so the now-mounted page catches it. Guard on the tab to avoid a loop. - if selectedIndex != SidebarNavItem.dashboard.rawValue { - selectedIndex = SidebarNavItem.dashboard.rawValue - DispatchQueue.main.async { - NotificationCenter.default.post(name: .navigateToChat, object: nil) - } - } + // is the shell's Chat route. Selecting it is idempotent, so no re-emit + // loop guard is needed. + chatFirstNavigation.selectPrimary(.chat) } - // "Continue in Omi" from the floating bar. The legacy Dashboard owns its - // existing pending-request consumption, while the Chat-first shell has no - // Dashboard chat panel to consume it on its behalf. + // "Continue in Omi" from the floating bar. The one shell has no second chat + // panel to consume the pending request on its behalf. .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in handleMainChatRequest() } @@ -502,15 +471,7 @@ struct DesktopHomeView: View { } private func handleMainChatRequest() { - guard usesChatFirstShell else { - selectedIndex = SidebarNavItem.dashboard.rawValue - return - } - consumePendingMainChatRequestForChatFirstShell() - } - - private func consumePendingMainChatRequestForChatFirstShell() { - guard usesChatFirstShell, MainChatNavigationRequestStore.shared.consume() else { return } + guard MainChatNavigationRequestStore.shared.consume() else { return } chatFirstNavigation.selectPrimary(.chat, origin: .chatDeeplink) } @@ -614,54 +575,6 @@ struct DesktopHomeView: View { } } - /// Redirect to conversations if current page isn't visible at the current tier level - private func redirectIfPageHidden() { - guard !usesChatFirstShell else { return } - // Tier 0 or tier 6+ shows everything — no redirect needed - guard currentTierLevel > 0 && currentTierLevel < 6 else { return } - // Don't redirect from settings/permissions pages - let nonMainPages: Set<Int> = [ - SidebarNavItem.settings.rawValue, SidebarNavItem.permissions.rawValue, - ] - guard !nonMainPages.contains(selectedIndex) else { return } - - var visibleRawValues: Set<Int> = [ - SidebarNavItem.dashboard.rawValue, SidebarNavItem.rewind.rawValue, - ] - if currentTierLevel >= 2 { visibleRawValues.insert(SidebarNavItem.memories.rawValue) } - if currentTierLevel >= 3 { visibleRawValues.insert(SidebarNavItem.tasks.rawValue) } - // Conversations replaced Chat in the sidebar; tier 1 unlocks it. - if currentTierLevel >= 1 { visibleRawValues.insert(SidebarNavItem.conversations.rawValue) } - - if !visibleRawValues.contains(selectedIndex) { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - } - - /// Whether to hide the sidebar (rewind mode) - private var hideSidebar: Bool { - OMIApp.launchMode == .rewind - } - - private var showsPrimarySidebar: Bool { - !usesChatFirstShell && useLegacyHomeDesign && !hideSidebar - } - - /// The constant floating top bar (nav + new-item counts + Capture/Listening) - /// replaces the old left nav rail. It shows on every main content page — - /// including Settings, whose page has no back button, so the bar's nav pills - /// are the way out. Permissions is a full-screen utility flow with its own - /// chrome and stays bar-less — the Memory atlas is the same: it has its - /// own back affordance and header, so the redundant top bar hides while it's open. - private var showsTopBar: Bool { - !useLegacyHomeDesign && SidebarNavItem(rawValue: selectedIndex) != nil - } - - /// Reference instant for the top bar's "new since you were last here" counts. - private var topBarSinceDate: Date { - topBarNewSinceRaw > 0 ? Date(timeIntervalSince1970: topBarNewSinceRaw) : Date() - } - private func seedTopBarNewSinceIfNeeded() { let currentValue = topBarNewSinceRaw guard currentValue == 0 else { return } @@ -708,36 +621,32 @@ struct DesktopHomeView: View { let currentWindow = NSApp.windows.first(where: { $0.title.lowercased().hasPrefix("omi") && $0.isVisible }) - let priorHomeMode = DesktopAutomationStateStore.shared.current().homeMode - let chatFirstRoute = usesChatFirstShell ? chatFirstNavigation.route : nil + let chatFirstRoute = chatFirstNavigation.route let snapshot = DesktopAutomationSnapshot( bridgeEnabled: true, bridgePort: DesktopAutomationLaunchOptions.port, bundleIdentifier: Bundle.main.bundleIdentifier ?? "unknown", appState: currentAppStateLabel, - selectedTab: chatFirstRoute?.title ?? SidebarNavItem(rawValue: selectedIndex)?.title, - selectedTabIndex: usesChatFirstShell ? nil : selectedIndex, - selectedSettingsSection: usesChatFirstShell - ? (chatFirstRoute == .more(.settings) ? selectedSettingsSection.rawValue : nil) - : (isInSettings ? selectedSettingsSection.rawValue : nil), + selectedTab: chatFirstRoute.title, + selectedTabIndex: nil, + selectedSettingsSection: chatFirstRoute == .more(.settings) + ? selectedSettingsSection.rawValue : nil, highlightedSettingId: highlightedSettingId, - usesLegacyHomeDesign: !usesChatFirstShell && useLegacyHomeDesign, - // Carried from `DashboardPage`, the stage's only writer, or nil when no surface renders one. - // Never defaulted — see `HomeStageAutomationPolicy`. - homeMode: HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: usesChatFirstShell, - chatFirstRoute: chatFirstRoute, - lastPublishedMode: priorHomeMode), - shellVariant: chatFirstCapabilitySample.variant.stableName, - chatFirstRoute: chatFirstRoute?.stableName, - visibleChatFirstRoute: usesChatFirstShell ? chatFirstNavigation.visibleRoute?.stableName : nil, + // There is one shell and it renders no Home stage: `DashboardPage` was its + // only writer and no longer exists. `nil` says exactly that — never a + // plausible-looking default a flow could wait on forever. + homeMode: nil, + // Pinned: the app has one shell. Retained in the snapshot because e2e + // flows and the navigation-visibility policy read it. + shellVariant: DesktopAutomationSnapshot.singleShellVariant, + chatFirstRoute: chatFirstRoute.stableName, + visibleChatFirstRoute: chatFirstNavigation.visibleRoute?.stableName, pendingFocusKind: chatFirstNavigation.pendingFocus?.stableName, acknowledgedFocusKind: chatFirstNavigation.lastAcknowledgedFocusKind, focusedEntityID: chatFirstNavigation.focusedEntityID, isFocusedEntityAcknowledged: chatFirstNavigation.isFocusedEntityAcknowledged, - showsPrimarySidebar: showsPrimarySidebar, - isSidebarCollapsed: usesChatFirstShell - ? chatFirstNavigation.isSidebarCollapsed : isSidebarCollapsed, + showsPrimarySidebar: false, + isSidebarCollapsed: chatFirstNavigation.isSidebarCollapsed, hasCompletedOnboarding: appState.hasCompletedOnboarding, isSignedIn: authState.isSignedIn, isRestoringAuth: authState.isRestoringAuth, @@ -789,15 +698,21 @@ struct DesktopHomeView: View { return } - if usesChatFirstShell, let route = ChatFirstRoute.automationVisibilityDestination(named: target) { + // `navigate help` used to name a "Help from Founder" page that no shell has + // mounted for a long time, so the bridge resolved a title and then timed out + // waiting for it. Settings → About is where getting help from a person + // actually lives (the Community / Join Discord card), so the name now lands + // on a destination that exists. + if ChatFirstRoute.isHelpAutomationTarget(target), settingsSectionRaw == nil { + selectedSettingsSection = .about + } + if let route = ChatFirstRoute.automationVisibilityDestination(named: target) { switch route { case .more(let page): chatFirstNavigation.selectMore(page) default: chatFirstNavigation.selectPrimary(route) } - } else if let item = SidebarNavItem.automationDestination(named: target) { - navigateToLegacyDestination(item) } reportAutomationState() @@ -1028,41 +943,29 @@ struct DesktopHomeView: View { restorePersistedCaptureServices(reason: "settings sync") } - private func updateStoreActivity(for index: Int) { - viewModelContainer.tasksStore.isActive = - index == SidebarNavItem.dashboard.rawValue || index == SidebarNavItem.tasks.rawValue - viewModelContainer.memoriesViewModel.isActive = - index == SidebarNavItem.conversations.rawValue || index == SidebarNavItem.memories.rawValue - } - - private var usesChatFirstShell: Bool { - DesktopShellPresentationPolicy.usesChatFirst(useLegacyHomeDesign, chatFirstCapabilitySample.variant) - } - private func updateStoreActivityForCurrentShell() { - guard usesChatFirstShell else { - updateStoreActivity(for: selectedIndex) - return - } viewModelContainer.tasksStore.isActive = chatFirstNavigation.route == .tasks || chatFirstNavigation.route == .more(.dashboard) viewModelContainer.memoriesViewModel.isActive = chatFirstNavigation.route == .memories } - /// One fresh server read decides both the shell and the local runtime - /// projection. A failed response, missing owner, stale auth snapshot, or - /// owner change resolves legacy; there is no cached local enablement. + /// One fresh server read decides the local runtime projection. It does not + /// decide which shell mounts — there is only one — so the shell is already on + /// screen while this runs. A failed response, missing owner, stale auth + /// snapshot, or owner change resolves capability-off: rich blocks still + /// render, kernel features stay dormant. private func resolveChatFirstCapabilityIfNeeded() async { - guard case .unresolved = chatFirstCapabilitySample.variant else { return } + guard !chatFirstCapability.isResolved else { return } guard let ownerID = RuntimeOwnerIdentity.currentOwnerId(), let authorization = RuntimeOwnerIdentity.captureAuthorizationSnapshot(expectedOwnerID: ownerID) else { - chatFirstCapabilitySample.resolve( + chatFirstCapability.resolve( control: nil, requestedOwnerID: nil, ownerIsStillCurrent: false ) _ = viewModelContainer.chatProvider.configureChatFirstMainChatCapability(nil) + log("DesktopHomeView: chat-first capability off — no owner or authorization snapshot at sample time") AnalyticsManager.shared.chatFirst( .capabilityResolution( outcome: .unavailable, @@ -1083,7 +986,7 @@ struct DesktopHomeView: View { let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) && RuntimeOwnerIdentity.currentOwnerId() == ownerID - chatFirstCapabilitySample.resolve( + chatFirstCapability.resolve( control: control, requestedOwnerID: ownerID, ownerIsStillCurrent: current @@ -1092,26 +995,27 @@ struct DesktopHomeView: View { let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) && RuntimeOwnerIdentity.currentOwnerId() == ownerID - chatFirstCapabilitySample.resolve( + chatFirstCapability.resolve( control: nil, requestedOwnerID: ownerID, ownerIsStillCurrent: current ) capabilityErrorClass = .unavailable - log("DesktopHomeView: chat-first control unavailable; using legacy shell") + log("DesktopHomeView: chat-first control unavailable; capability stays off") } let projectionConfigured = viewModelContainer.chatProvider.configureChatFirstMainChatCapability( - chatFirstCapabilitySample.variant.projection + chatFirstCapability.projection ) if !projectionConfigured { // A pre-existing Main Chat session cannot be retroactively upgraded with - // dynamic tools. Keep this launch on the byte-equivalent legacy path. - chatFirstCapabilitySample.failClosed() + // dynamic tools. Fail closed to capability-off for this launch; the shell + // and its content blocks are unaffected. + chatFirstCapability.failClosed() capabilityErrorClass = .projectionRejected - log("DesktopHomeView: chat-first projection handoff rejected; using legacy shell") + log("DesktopHomeView: chat-first projection handoff rejected; capability stays off") } - let projection = chatFirstCapabilitySample.variant.projection + let projection = chatFirstCapability.projection let capabilityOutcome: ChatFirstAnalyticsEvent.CapabilityOutcome if capabilityErrorClass == .projectionRejected { capabilityOutcome = .projectionRejected @@ -1129,17 +1033,15 @@ struct DesktopHomeView: View { errorClass: capabilityErrorClass ) ) + log( + "DesktopHomeView: chat-first capability resolved outcome=\(capabilityOutcome) " + + "generation=\(projection.map { String($0.controlGeneration) } ?? "none")") reportAutomationState() } private func navigateAfterOnboarding() { - if usesChatFirstShell { - chatFirstNavigation.selectPrimary(.chat) - log("DesktopHomeView: Onboarding just completed — opening Chat") - } else { - selectedIndex = SidebarNavItem.dashboard.rawValue - log("DesktopHomeView: Onboarding just completed — navigating to Dashboard") - } + chatFirstNavigation.selectPrimary(.chat) + log("DesktopHomeView: Onboarding just completed — opening Chat") } /// Existing menu, keyboard, and automation callers retain their legacy @@ -1148,21 +1050,13 @@ struct DesktopHomeView: View { private func navigateToLegacyDestination(_ item: SidebarNavItem) { if item == .permissions { selectedSettingsSection = .permissions - if usesChatFirstShell { - chatFirstNavigation.selectMore(.settings) - } else { - selectedIndex = SidebarNavItem.settings.rawValue - } + chatFirstNavigation.selectMore(.settings) return } if let destination = MemoryHubDestination.destination(for: item) { memoryDestinationRawValue = destination.rawValue } - if usesChatFirstShell { - chatFirstNavigation.selectLegacyDestination(item) - } else { - selectedIndex = item.rawValue - } + chatFirstNavigation.selectLegacyDestination(item) } private var mainContent: some View { @@ -1198,7 +1092,7 @@ struct DesktopHomeView: View { await RatingPromptManager.shared.seedFromHistoryIfNeeded() } .overlay { - if !usesChatFirstShell && showTryAskingPopup { + if showTryAskingPopup { TryAskingPopupView( onTry: { useCase in showTryAskingPopup = false @@ -1217,7 +1111,9 @@ struct DesktopHomeView: View { private func mainContentWithNotifications<Content: View>(_ content: Content) -> some View { content .onReceive(NotificationCenter.default.publisher(for: .showTryAskingPopup)) { _ in - guard !usesChatFirstShell else { return } + // The first-use popup belongs to whoever just finished onboarding, and + // there is now one shell for all of them. `shouldArmPopup` upstream is + // still the only thing that decides whether it is due. showTryAskingPopup = true } .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindSettings)) { _ in @@ -1252,12 +1148,7 @@ struct DesktopHomeView: View { } } .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in - if usesChatFirstShell { - chatFirstNavigation.selectPrimary(.chat) - } else { - // Legacy Home owns the historic Chat notification contract. - selectedIndex = SidebarNavItem.dashboard.rawValue - } + chatFirstNavigation.selectPrimary(.chat) } .onReceive(NotificationCenter.default.publisher(for: .navigateToTasks)) { _ in navigateToLegacyDestination(.tasks) @@ -1286,7 +1177,7 @@ struct DesktopHomeView: View { } .onReceive(NotificationCenter.default.publisher(for: .desktopAutomationOpenMemoryAtlasRequested)) { _ in memoryDestinationRawValue = MemoryHubDestination.brainMap.rawValue - selectedIndex = SidebarNavItem.conversations.rawValue + chatFirstNavigation.selectPrimary(.memories) } .onReceive(NotificationCenter.default.publisher(for: .desktopAutomationOpenConversationRequested)) { _ in memoryDestinationRawValue = MemoryHubDestination.conversations.rawValue @@ -1296,321 +1187,27 @@ struct DesktopHomeView: View { private func mainContentWithLifecycle<Content: View>(_ content: Content) -> some View { content - .onChange(of: selectedIndex) { oldValue, newValue in - if newValue == SidebarNavItem.settings.rawValue - && oldValue != SidebarNavItem.settings.rawValue - { - previousIndexBeforeSettings = oldValue - } - updateStoreActivity(for: newValue) - } .onChange(of: chatFirstNavigation.route) { _, _ in updateStoreActivityForCurrentShell() reportAutomationState() } .onChange(of: chatFirstNavigation.visibleRoute) { _, _ in reportAutomationState() } .onChange(of: chatFirstNavigation.isSidebarCollapsed) { _, _ in reportAutomationState() } - .onChange(of: useLegacyHomeDesign) { _, newValue in - if usesChatFirstShell { showTryAskingPopup = false } - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isSidebarCollapsed = !newValue - } - } .onAppear { - if case .legacy = chatFirstCapabilitySample.variant { - isSidebarCollapsed = !useLegacyHomeDesign - } updateStoreActivityForCurrentShell() restorePreChatWindowWidth() } } - /// Keep the legacy HStack out of the chat-first branch's SwiftUI generic - /// expression. The runtime choice is already immutable for this app session; - /// this is only an erased rendering boundary, not a second state owner. - @ViewBuilder private var shellContent: some View { - if case (true, .chatFirst(let capability)) = (usesChatFirstShell, chatFirstCapabilitySample.variant) { - ChatFirstShell( - navigation: chatFirstNavigation, - appState: appState, - viewModelContainer: viewModelContainer, - capability: capability, - selectedSettingsSection: $selectedSettingsSection, - highlightedSettingID: $highlightedSettingId - ) - } else { - legacyMainContent - } - } - - private var legacyMainContent: some View { - HStack(spacing: 0) { - sidebarSlot - mainContentContainer - } - } - - // Sidebar slot: settings sidebar overlays main sidebar - // IMPORTANT: SidebarView is kept alive (but hidden) when in settings to prevent - // EXC_BAD_ACCESS crash in SwiftUI's tooltip system. When the view is conditionally - // removed, its .help() tooltip graph nodes get invalidated, but the macOS tooltip - // tracking system still tries to evaluate them during window key state changes. - // - // Extracted from `mainContent` (rather than inlined in its HStack) so the - // compiler type-checks each slot independently instead of one very large - // combined expression. - @ViewBuilder - private var sidebarSlot: some View { - if showsPrimarySidebar { - LegacySidebarSurface { - ZStack { - SidebarView( - selectedIndex: $selectedIndex, - isCollapsed: $isSidebarCollapsed, - memoryDestinationRawValue: $memoryDestinationRawValue, - appState: appState - ) - .opacity(isInSettings ? 0 : 1) - .allowsHitTesting(!isInSettings) - if isInSettings { settingsSidebar } - } - } - } - } - - /// The settings section list. Modern settings hosts it inside the page panel; legacy Home hosts the - /// whole sidebar slot on `LegacySidebarSurface`, so this view always inherits a glass ground. - private var settingsSidebar: some View { - SettingsSidebar( - selectedSection: $selectedSettingsSection, - highlightedSettingId: $highlightedSettingId, - onBack: { - OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = - previousIndexBeforeSettings == SidebarNavItem.settings.rawValue - ? SidebarNavItem.dashboard.rawValue - : previousIndexBeforeSettings - } - }, appState: appState) - } - - // Main content area. It paints **no background**: the window has no ground at all - // (`ShellWindowChrome`), so each destination floats on its own panel and one painted - // here would slip an opaque sheet between the desktop and every `.behindWindow` blur. - private var mainContentContainer: some View { - // Page content - switch recreates views on tab change - // Extracted into a separate struct so that pages like TasksPage - // are not re-rendered when AppState publishes unrelated changes. - VStack(spacing: 0) { - // Constant floating top bar — primary nav, new-item counts, and the - // Capture/Listening controls. Replaces the old left nav rail. Hidden - // for the Memory atlas (see showsTopBar), which has its own chrome. - if showsTopBar { - DesktopTopBar( - selectedIndex: $selectedIndex, - memoryDestinationRawValue: $memoryDestinationRawValue, - appState: appState, - memoriesViewModel: viewModelContainer.memoriesViewModel, - tasksStore: viewModelContainer.tasksStore, - sinceDate: topBarSinceDate - ) - .zIndex(1) - } - - // One panel per destination — see `PageGlassLane`. Settings' own section list rides inside it - // so the page is one object rather than a panel with its nav stranded on the wallpaper. - PageGlassLane( - selectedIndex: selectedIndex, - homeOwnsItsPanels: homeOwnsItsPanels - ) { - HStack(spacing: 0) { - if isInSettings && !showsPrimarySidebar { settingsSidebar } - PageContentView( - selectedIndex: selectedIndex, - appState: appState, - viewModelContainer: viewModelContainer, - memoryDestinationRawValue: $memoryDestinationRawValue, - selectedSettingsSection: $selectedSettingsSection, - highlightedSettingId: $highlightedSettingId, - selectedTabIndex: $selectedIndex - ) - } - } - } - .onEscapeKey(priority: .navigation) { navigateHomeOnEscapeIfNeeded() } - // The top bar occupies the hidden title-bar band; the window's top edge is the glass. - .padding(.top, GlassShell.titlebarClearance) - } - - private func navigateHomeOnEscapeIfNeeded() -> Bool { - if usesChatFirstShell { - guard chatFirstNavigation.route != .chat else { return false } - OmiMotion.withGated(Self.pageNavigationAnimation) { - chatFirstNavigation.selectPrimary(.chat) - } - return true - } - guard - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: selectedIndex, - usesLegacyHomeDesign: useLegacyHomeDesign - ) - else { return false } - OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - return true - } -} - -private struct ChatFirstCapabilityLoadingView: View { - var body: some View { - TransparentWindowStatusPanel { - VStack(spacing: OmiSpacing.md) { - ProgressView() - .controlSize(.small) - .tint(Ink.secondary) - Text("Preparing Omi…") - .inkStyle(.prose, color: Ink.secondary) - } - } - // The main window is transparent and the destination shell has not mounted yet. This loading - // card therefore owns its ground rather than assuming a window-scale surface underneath it. - .accessibilityElement(children: .combine) - .accessibilityLabel("Preparing Omi") - } -} - -private struct PageChromeBar: View { - let onHome: () -> Void - - var body: some View { - HStack(spacing: OmiSpacing.sm) { - PageChromeButton(title: "Home", systemImage: "house.fill", action: onHome) - Spacer() - } - .frame(height: 34) - } -} - -private struct PageChromeButton: View { - let title: String - let systemImage: String - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.xs) { - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - } - .foregroundStyle(isHovering ? Ink.primary : Ink.secondary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.xs) - // Never `Material`: that is within-window vibrancy and would frost the - // page under this pill instead of the desktop. A wash is the shape here. - .background(GlassPillBackground(isSelected: false, isHovering: isHovering)) - .overlay( - Capsule(style: .continuous) - .strokeBorder(Ink.hairline, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} - -private struct PageContentView: View { - let selectedIndex: Int - let appState: AppState - let viewModelContainer: ViewModelContainer - @Binding var memoryDestinationRawValue: Int - @Binding var selectedSettingsSection: SettingsContentView.SettingsSection - @Binding var highlightedSettingId: String? - @Binding var selectedTabIndex: Int - - /// The list/detail pages (Conversations, Memories, Tasks, Apps) render their - /// content in a centered, width-capped column so wide monitors get calm - /// gutters instead of a full-bleed stretch. Pages paint a clear background, so - /// the gutters show the shell surface seamlessly. - @ViewBuilder - private func constrainedListPage<V: View>(_ page: V) -> some View { - page - .frame(maxWidth: MemoryHubLayoutPolicy.readableContentWidth, maxHeight: .infinity) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - var body: some View { - pages - } - - @ViewBuilder - private var pages: some View { - Group { - switch selectedIndex { - case 0: - QueryShellHome( - viewModel: viewModelContainer.dashboardViewModel, - homeStatusStore: viewModelContainer.homeStatusStore, - appState: appState, - appProvider: viewModelContainer.appProvider, - chatProvider: viewModelContainer.chatProvider, - memoriesViewModel: viewModelContainer.memoriesViewModel, - taskChatCoordinator: viewModelContainer.taskChatCoordinator, - selectedIndex: $selectedTabIndex) - case SidebarNavItem.conversations.rawValue, - SidebarNavItem.memories.rawValue, - SidebarNavItem.rewind.rawValue: - MemoryHubPage( - appState: appState, - viewModelContainer: viewModelContainer, - memoriesViewModel: viewModelContainer.memoriesViewModel, - destinationRawValue: $memoryDestinationRawValue - ) - case 4: - constrainedListPage( - TasksPage( - viewModel: viewModelContainer.tasksViewModel, - chatCoordinator: viewModelContainer.taskChatCoordinator, - chatProvider: viewModelContainer.chatProvider, - onOpenRewindEvidence: { screenshotID in - RewindCitationFocusState.shared.request(screenshotID) - memoryDestinationRawValue = MemoryHubDestination.rewind.rawValue - selectedTabIndex = SidebarNavItem.rewind.rawValue - })) - case 8: - constrainedListPage( - AppsPage( - appProvider: viewModelContainer.appProvider, - appState: appState, - connectorStatusStore: viewModelContainer.homeStatusStore.connectorStatusStore, - handlesAutomationPresentations: viewModelContainer.isInitialLoadComplete)) - case SidebarNavItem.settings.rawValue, SidebarNavItem.permissions.rawValue: - SettingsPage( - appState: appState, - selectedSection: $selectedSettingsSection, - highlightedSettingId: $highlightedSettingId, - chatProvider: viewModelContainer.chatProvider - ) - default: - QueryShellHome( - viewModel: viewModelContainer.dashboardViewModel, - homeStatusStore: viewModelContainer.homeStatusStore, - appState: appState, - appProvider: viewModelContainer.appProvider, - chatProvider: viewModelContainer.chatProvider, - memoriesViewModel: viewModelContainer.memoriesViewModel, - taskChatCoordinator: viewModelContainer.taskChatCoordinator, - selectedIndex: $selectedTabIndex) - } - } + ChatFirstShell( + navigation: chatFirstNavigation, + appState: appState, + viewModelContainer: viewModelContainer, + capability: chatFirstCapability.projection, + selectedSettingsSection: $selectedSettingsSection, + highlightedSettingID: $highlightedSettingId + ) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift index 35024ecc421..583505f58c2 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift @@ -1,46 +1,3 @@ -enum DesktopShellPresentationPolicy { - static func usesChatFirst(_ useLegacyHomeDesign: Bool, _ capabilityVariant: ChatFirstShellVariant) -> Bool { - guard !useLegacyHomeDesign else { return false } - if case .chatFirst = capabilityVariant { return true } - return false - } - - /// The first-use popup belongs to the legacy shell. Chat-first owns its starter prompts inside - /// the main chat. Neither shell exposes the notch as a text-entry surface. - static func usesLegacyPostOnboardingPopup( - _ useLegacyHomeDesign: Bool, - _ capabilityVariant: ChatFirstShellVariant - ) -> Bool { - !usesChatFirst(useLegacyHomeDesign, capabilityVariant) - } -} - -enum HomeDesignPresentation: Equatable { - case queryShell - case redesignedHub - case oldestLegacy - - static func resolve( - useLegacyHomeDesign: Bool, - useOldestHomeDesign: Bool, - forceModernPresentation: Bool - ) -> Self { - guard !forceModernPresentation, useLegacyHomeDesign else { return .queryShell } - return useOldestHomeDesign ? .oldestLegacy : .redesignedHub - } - - static func queryShellOwnsItsPanels( - useLegacyHomeDesign: Bool, - forceModernPresentation: Bool - ) -> Bool { - resolve( - useLegacyHomeDesign: useLegacyHomeDesign, - useOldestHomeDesign: false, - forceModernPresentation: forceModernPresentation - ) == .queryShell - } -} - /// The notch is not a text surface. Typed conversation lives in the main window on every shell, so /// backing out of an agent chat with nothing else to show in the notch lands in the main chat /// rather than an empty composer. This used to be a per-shell flag (chat-first only); the legacy @@ -51,52 +8,3 @@ enum FloatingPrimaryTextInputRouting { !hasMainConversation } } - -/// Whether the Home stage — `HomeStageMode`'s hub / chat / connect — is mounted at all, and therefore -/// whether `DesktopAutomationSnapshot.homeMode` has anything true to say. -/// -/// **`DashboardPage` renders that stage and is its only writer.** The shell must never synthesize a -/// value for it. This began as an inline `(priorHomeMode ?? "hub")` guarded on "not chat-first, not -/// legacy, on the Dashboard tab" — which named `DashboardPage` exactly, on the day it was written. -/// Home then became `QueryShellHome`, a surface with no stage at all, and that same guard went on -/// answering `hub` forever. -/// -/// **A fabricated reading is worse than a missing one**, because `hub` is *plausible*. Nothing looks -/// broken: a flow waiting for `chat` waits for a transition that can never arrive, a flow asserting -/// `hub` passes without touching the app, and an agent reading `/state` draws a confident wrong -/// conclusion about a surface that is not on screen. `nil` says the one true thing — this shell has -/// no stage — and every reader already handles it, because legacy Home has always reported `nil`. -enum HomeStageAutomationPolicy { - - /// The last mode `DashboardPage` published, or `nil` when nothing is rendering the stage. Never a - /// default and never a guess: the shell's job here is to carry the owner's value or say there is - /// no owner. - static func reportedHomeMode( - usesChatFirstShell: Bool, - chatFirstRoute: ChatFirstRoute?, - lastPublishedMode: String? - ) -> String? { - guard usesChatFirstShell, let chatFirstRoute, mountsHomeStage(chatFirstRoute) else { return nil } - return lastPublishedMode - } - - /// The routes that mount `DashboardPage`, the only view that renders the stage. - /// - /// The legacy shell has no entry here on purpose, and that is the whole correction: its Home is - /// `QueryShellHome`, which renders the query surface, and the one branch that still mounts - /// `DashboardPage` there requires `useLegacyHomeDesign` — which routes to `legacyHome`. No stage - /// either way. - /// - /// An exhaustive `switch` rather than a `default`, so a route added later has to state its answer - /// instead of inheriting "reports a stage mode" from a fallthrough. - static func mountsHomeStage(_ route: ChatFirstRoute) -> Bool { - switch route { - case .chat: - return true - case .more(let page): - return page == .dashboard - case .conversations, .tasks, .goals, .memories: - return false - } - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift index 30c8abdb0a1..4eaf0361ec1 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift @@ -172,7 +172,7 @@ struct DesktopUpdateStatusChipLabel: View { } /// Compact chip shown in `DesktopTopBar` so chat-first shell users see Sparkle -/// progress (the legacy sidebar widget is unreachable when `usesChatFirstShell`). +/// progress (the legacy sidebar widget it used to share this job with is gone). struct DesktopUpdateStatusChip: View { @ObservedObject private var updaterViewModel = UpdaterViewModel.shared @State private var glowAnimating = false diff --git a/desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift b/desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift deleted file mode 100644 index 7c5b96c2c8d..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// LegacySidebarSurface.swift — the one ground under the old Home sidebar slot. -// - -import OmiTheme -import SwiftUI - -/// Hosts the old Home navigation slot on its own piece of glass. -/// -/// `ShellWindowChrome` leaves the top-level window transparent and `PageGlassLane` grounds only the -/// destination beside this slot. Keeping the surface here means the primary navigation and the -/// Settings menu share one owner for both their visible glass and their mouse-hit region. -struct LegacySidebarSurface<Content: View>: View { - private let content: Content - private let reduceTransparency: Bool? - - init(reduceTransparency: Bool? = nil, @ViewBuilder content: () -> Content) { - self.content = content() - self.reduceTransparency = reduceTransparency - } - - var body: some View { - content - .fixedSize(horizontal: true, vertical: false) - .clipped() - .inkGlassPanel(cornerRadius: 0, shadow: nil, reduceTransparency: reduceTransparency) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift b/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift index 72a0cad3855..fae6edf8bb4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift @@ -6,9 +6,9 @@ import Foundation /// the conversation the user asked to continue would be nowhere in sight. /// /// Flow: the raiser calls `request()` (which also posts -/// `.openMainChatRequested`); `DesktopHomeView` switches to the Home tab on -/// the notification, and `DashboardPage` consumes the pending request when it -/// is (or becomes) visible and opens the chat panel. +/// `.openMainChatRequested`); `DesktopHomeView` selects the Chat route on the +/// notification, and `QueryShellHome` — the one chat destination — takes any +/// pending draft when its composer mounts or is already mounted. @MainActor final class MainChatNavigationRequestStore { static let shared = MainChatNavigationRequestStore() diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift deleted file mode 100644 index a73a8e22610..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ /dev/null @@ -1,4268 +0,0 @@ -import AppKit -import Combine -import OmiTheme -import SwiftUI -import UniformTypeIdentifiers - -// MARK: - Dashboard View Model - -@MainActor -class DashboardViewModel: ObservableObject { - // Observe the shared TasksStore - private let tasksStore = TasksStore.shared - - @Published var scoreResponse: ScoreResponse? - @Published var goals: [Goal] = [] - @Published var isLoading = false - @Published var error: String? - - private var cancellables = Set<AnyCancellable>() - private var lastGoalRefreshTime: Date = .distantPast - - // Computed properties that delegate to TasksStore - var overdueTasks: [TaskActionItem] { tasksStore.overdueTasks } - var todaysTasks: [TaskActionItem] { tasksStore.todaysTasks } - var recentTasks: [TaskActionItem] { tasksStore.tasksWithoutDueDate } - - init() { - // Forward TasksStore changes to trigger view updates - tasksStore.objectWillChange - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - // Load goals from local SQLite for instant display - loadGoalsFromLocal() - - // Refresh goals when one is auto-created - NotificationCenter.default.publisher(for: .goalAutoCreated) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - Task { [weak self] in - await self?.loadGoals() - } - } - .store(in: &cancellables) - } - - func loadDashboardData() async { - isLoading = true - error = nil - - // Load all data in parallel - async let scoreTask: Void = loadScores() - async let tasksTask: Void = tasksStore.refreshDashboardTasksFromServer() - async let goalsTask: Void = loadGoals() - - let _ = await (scoreTask, tasksTask, goalsTask) - - isLoading = false - } - - func loadCachedDashboardData() async { - await loadGoalsFromLocalSnapshot() - } - - func resetSessionState() { - scoreResponse = nil - goals = [] - isLoading = false - error = nil - lastGoalRefreshTime = .distantPast - } - - private func loadScores() async { - do { - scoreResponse = try await APIClient.shared.getScores() - } catch { - logError("Failed to load scores", error: error) - } - } - - private func loadGoals() async { - // 1. Show local data first (already loaded in init) - // 2. Fetch from API - do { - let apiGoals = try await APIClient.shared.getGoals() - // 3. Sync to SQLite - try await GoalStorage.shared.syncServerGoals(apiGoals) - // 4. Reload from SQLite (source of truth) - goals = try await GoalStorage.shared.getLocalGoals() - lastGoalRefreshTime = Date() - } catch { - logError("Failed to load goals", error: error) - } - } - - /// Refresh goals with 30-second debounce (for app lifecycle events) - func refreshGoals() { - let now = Date() - guard now.timeIntervalSince(lastGoalRefreshTime) > 30 else { return } - Task { - await loadGoals() - } - } - - // MARK: - Local Goals Storage - - private func loadGoalsFromLocal() { - Task { - await loadGoalsFromLocalSnapshot() - } - } - - private func loadGoalsFromLocalSnapshot() async { - do { - goals = try await GoalStorage.shared.getLocalGoals() - } catch { - logError("Failed to load goals from local storage", error: error) - } - } - - func toggleTaskCompletion(_ task: TaskActionItem) async { - // Delegate to shared store - it handles the update - await tasksStore.toggleTask(task) - // Reload scores after task completion change - await loadScores() - } - - func createGoal(title: String, goalType: GoalType, targetValue: Double, unit: String?) async { - do { - let goal = try await APIClient.shared.createGoal( - title: title, - goalType: goalType, - targetValue: targetValue, - unit: unit, - source: "user" - ) - _ = try? await GoalStorage.shared.syncServerGoal(goal) - goals = try await GoalStorage.shared.getLocalGoals() - } catch { - logError("Failed to create goal", error: error) - } - } - - func updateGoalProgress(_ goal: Goal, currentValue: Double) async { - log("Goals: Updating '\(goal.title)' progress to \(currentValue)") - - // Optimistically update local SQLite - if let index = goals.firstIndex(where: { $0.id == goal.id }) { - goals[index].currentValue = currentValue - } - try? await GoalStorage.shared.updateProgress(backendId: goal.id, currentValue: currentValue) - - do { - let updated = try await APIClient.shared.updateGoalProgress( - goalId: goal.id, - currentValue: currentValue - ) - - // Sync API response to SQLite - _ = try? await GoalStorage.shared.syncServerGoal(updated) - - // Check if the backend auto-completed this goal - if updated.completedAt != nil { - log("Goals: '\(goal.title)' COMPLETED! Triggering celebration.") - goals = try await GoalStorage.shared.getLocalGoals() - NotificationCenter.default.post(name: .goalCompleted, object: updated) - return - } - - goals = try await GoalStorage.shared.getLocalGoals() - log("Goals: Updated '\(goal.title)' progress confirmed by API") - } catch { - logError("Failed to update goal progress", error: error) - } - } - - func updateGoal(_ goal: Goal, title: String, currentValue: Double, targetValue: Double) async { - log("Goals: Updating goal '\(goal.title)' -> title='\(title)', current=\(currentValue), target=\(targetValue)") - - do { - let updated = try await APIClient.shared.updateGoal( - goalId: goal.id, - title: title, - currentValue: currentValue, - targetValue: targetValue - ) - - _ = try? await GoalStorage.shared.syncServerGoal(updated) - goals = try await GoalStorage.shared.getLocalGoals() - log("Goals: Updated goal '\(updated.title)' confirmed by API") - } catch { - logError("Failed to update goal", error: error) - goals = (try? await GoalStorage.shared.getLocalGoals()) ?? goals - } - } - - func deleteGoal(_ goal: Goal) async { - do { - // Soft-delete locally first for instant UI update - try? await GoalStorage.shared.softDelete(backendId: goal.id) - goals = try await GoalStorage.shared.getLocalGoals() - // Then delete on backend - try await APIClient.shared.deleteGoal(id: goal.id) - } catch { - logError("Failed to delete goal", error: error) - } - } -} - -// MARK: - Dashboard Page - -struct DashboardPage: View { - @ObservedObject var viewModel: DashboardViewModel - @ObservedObject var homeStatusStore: HomeStatusStore = HomeStatusStore() - @ObservedObject var appState: AppState - @ObservedObject var appProvider: AppProvider - @ObservedObject var chatProvider: ChatProvider - @ObservedObject var memoriesViewModel: MemoriesViewModel - var taskChatCoordinator: TaskChatCoordinator? = nil - /// Present only for the capability-gated main-window Home chat. Shared - /// Dashboard callers leave this nil and keep journaled rich blocks inert. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil - /// The Chat-first shell reuses dashboard content under More, but Chat itself - /// has one primary home. Legacy callers leave this nil and retain their - /// inline Home chat exactly as before. - var onOpenPrimaryChat: (() -> Void)? = nil - @ObservedObject private var deviceProvider = DeviceProvider.shared - @ObservedObject private var homeSuggestionsStore = HomeSuggestionsStore.shared - @StateObject private var intelligenceStore = DashboardIntelligenceStore() - /// Learned insights ("things about you") — surfaced in the home hub's rotating - /// knows-list alongside tasks and asks, not just on the Insights page. - @ObservedObject private var insightStorage = InsightStorage.shared - @State private var homeAskFocusPolicy = HomeAskFocusPolicy() - @Binding var selectedIndex: Int - @State private var selectedCatalogApp: OmiApp? - @State private var selectedImportConnector: ImportConnector? - @State private var selectedExportDestination: MemoryExportDestination? - @State private var homeConnectSheetAcceptsInput = false - @State private var isCaptureMonitoring = false - @State private var isTogglingCapture = false - @State private var isTogglingListening = false - @State private var showingAllGoals = false - @State private var showingGoalDetail = false - @AppStorage("dashboardWidgetsCollapsed") private var widgetsCollapsed = false - @AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true - @AppStorage(AssistantSettings.audioRecordingModeDefaultsKey) private var audioRecordingModeRaw = - AssistantSettings.AudioRecordingMode.onlyMeetings.rawValue - @AppStorage("useLegacyHomeDesign") private var useLegacyHomeDesign = false - @AppStorage("useOldestHomeDesign") private var useOldestHomeDesign = false - @State private var homeMode: HomeStageMode = .hub - @State private var didReportChatFirstTranscriptPage = false - @FocusState private var homeAskFieldFocused: Bool - - private var routesChatToPrimaryShell: Bool { - onOpenPrimaryChat != nil - } - /// Rotation index for the home knows-list; a timer advances it so the hub - /// cycles through fresh suggestions while you're looking at it. - @State private var knowsRotation = 0 - private let knowsRotationTimer = Timer.publish(every: 7, on: .main, in: .common).autoconnect() - /// What the knows-list ledger said when this visit began. - /// - /// Composition is gated against the state at visit start, so rotating inside - /// one visit cannot suppress the rows you are currently looking at; the - /// across-visit rules (show cap, dismissal, same calendar day) still bite. - /// In-visit dismisses and opens are merged in below. - @State private var knowsLedger = HomeKnowsImpressionLedger.empty - /// Single mutation owner for that ledger — the view never writes it directly. - private var knowsLedgerStore: HomeKnowsImpressionStore { .shared } - - private var selectedApp: OmiApp? { - guard let appId = chatProvider.selectedAppId else { return nil } - return appProvider.chatApps.first { $0.id == appId } - } - - private var captureStatus: HomeStatusState { - CaptureListeningLogic.captureStatus(appState: appState, isCaptureMonitoring: isCaptureMonitoring) - } - - private var isCaptureLive: Bool { - CaptureListeningLogic.isCaptureLive(isCaptureMonitoring: isCaptureMonitoring) - } - - private var listeningModeTitle: String { - CaptureListeningLogic.listeningModeTitle(appState: appState, raw: audioRecordingModeRaw) - } - - private static let homeStageMaxWidth: CGFloat = 1360 - private static let homeStageMinSideInset: CGFloat = 30 - private static let homeStageMaxSideInset: CGFloat = 96 - private static let homeAskBarMinWidth: CGFloat = 560 - private static let homeAskBarMaxWidth: CGFloat = 980 - private static let homeStagePanelMaxWidth: CGFloat = 1280 - private static let homeChatColumnMaxWidth = ChatComposerLayout.contentLaneMaxWidth - private static let homeStageTopPadding: CGFloat = 74 - private static let homeStageBottomPadding: CGFloat = 26 - private static let homeStageAnimation = Animation.spring(response: 0.46, dampingFraction: 0.86) - private static let homeConnectSheetHorizontalMargin: CGFloat = 56 - private static let homeConnectSheetVerticalMargin: CGFloat = 44 - private static let homeConnectSheetMinWidth: CGFloat = 360 - private static let homeConnectSheetMinHeight: CGFloat = 360 - private static let homeConnectSheetCornerRadius: CGFloat = 24 - private static let appDetailSheetPreferredSize = CGSize(width: 500, height: 600) - private static let importConnectorSheetPreferredSize = CGSize(width: 520, height: 500) - private static let exportDestinationSheetPreferredSize = CGSize(width: 520, height: 560) - - private var homeConnectSheetIsPresented: Bool { - selectedCatalogApp != nil || selectedImportConnector != nil || selectedExportDestination != nil - } - - private var isHomeModalPresented: Bool { - homeConnectSheetIsPresented - } - - private var legacySelectedCatalogApp: Binding<OmiApp?> { - Binding( - get: { useLegacyHomeDesign ? selectedCatalogApp : nil }, - set: { selectedCatalogApp = $0 } - ) - } - - private var legacySelectedImportConnector: Binding<ImportConnector?> { - Binding( - get: { useLegacyHomeDesign ? selectedImportConnector : nil }, - set: { selectedImportConnector = $0 } - ) - } - - private var legacySelectedExportDestination: Binding<MemoryExportDestination?> { - Binding( - get: { useLegacyHomeDesign ? selectedExportDestination : nil }, - set: { selectedExportDestination = $0 } - ) - } - - private var hasOmiDeviceHistory: Bool { - deviceProvider.connectedDevice != nil || deviceProvider.pairedDevice != nil - || homeStatusStore.accountHasOmiDeviceConversations - } - - /// Real persisted import-connector state (UserDefaults-backed via ImportConnectorStatusStore). - private func isImportConnectorConnected(_ connectorID: String) -> Bool { - guard let connector = ImportConnector.all.first(where: { $0.id == connectorID }) else { return false } - return homeStatusStore.connectorStatusStore.snapshot(for: connector).isConnected - } - - private func isMCPDestinationConnected(_ destination: MemoryExportDestination) -> Bool { - switch destination { - case .claude, .claudeCode: - return [.claude, .claudeCode].contains { homeStatusStore.memoryExportStatuses[$0]?.hasConnection == true } - case .chatgpt, .codex: - return [.chatgpt, .codex].contains { homeStatusStore.memoryExportStatuses[$0]?.hasConnection == true } - default: - return homeStatusStore.memoryExportStatuses[destination]?.hasConnection == true - } - } - - var body: some View { - applyChatNavigation(to: applyHomeLifecycle(to: applyHomeSheets(to: homeSurface))) - } - - /// Opening chat from the notch / Ask-Omi shortcut (posts `.navigateToChat`) - /// lands in the live chat surface — which shares the notch's transcript — - /// rather than the resting hero. Kept in its own modifier so the main - /// lifecycle chain stays type-checkable. - private func applyChatNavigation<Content: View>(to content: Content) -> some View { - content - .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in - openHomeChat(focusInput: true) - } - } - - private var homeSurface: some View { - Group { - if useLegacyHomeDesign && useOldestHomeDesign && !routesChatToPrimaryShell { - legacyHome - } else { - redesignedHome - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - // `PageGlassLane.panel` supplies the ground for older Home surfaces; keep this clear to match it. - .background(Color.clear) - } - - private func applyHomeSheets<Content: View>(to content: Content) -> some View { - content - .sheet(isPresented: $showingAllGoals) { - AllGoalsSheet( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onDismiss: { showingAllGoals = false } - ) - } - .sheet(isPresented: $showingGoalDetail) { - if let detail = intelligenceStore.selectedGoalDetail { - CanonicalGoalDetailSheet( - detail: detail, - error: intelligenceStore.error, - onResumeThread: { workstreamID in - _ = await resumeThread(workstreamID: workstreamID, taskID: nil) - }, - onStartWork: { await startWorkFromSelectedGoal() }, - onDismiss: { - showingGoalDetail = false - intelligenceStore.clearGoalDetail() - } - ) - } else { - ProgressView().frame(width: 300, height: 180) - } - } - .dismissableSheet(item: legacySelectedCatalogApp) { app in - AppDetailSheet(app: app, appProvider: appProvider, onDismiss: { selectedCatalogApp = nil }) - .frame(width: 500, height: 650) - .onAppear { - AnalyticsManager.shared.appDetailViewed(appId: app.id, appName: app.name) - } - } - .dismissableSheet(item: legacySelectedImportConnector) { connector in - ImportConnectorSheet( - connector: connector, - appState: appState, - statusStore: homeStatusStore.connectorStatusStore, - onDismiss: { - selectedImportConnector = nil - } - ) - .frame(width: 520, height: 620) - } - .dismissableSheet(item: legacySelectedExportDestination) { destination in - ConnectDestinationSheet( - destination: destination, - statuses: $homeStatusStore.memoryExportStatuses, - onDismiss: { - selectedExportDestination = nil - } - ) - .frame(width: 520, height: 620) - } - } - - // Split in two (`applyHomeLifecycle` → `applyHomeStageObservers`) so each - // modifier chain stays within the type-checker's budget. - private func applyHomeLifecycle<Content: View>(to content: Content) -> some View { - applyHomeStageObservers(to: applyHomeLifecycleCore(to: content)) - } - - private func applyHomeLifecycleCore<Content: View>(to content: Content) -> some View { - content - .onAppear { - // The "try asking" popup is armed by the shell that owns its overlay - // (`DesktopHomeView`), not from here: this page is only Home behind - // `useLegacyHomeDesign`, and while it held the only trigger the popup - // could not fire on the default Home at all. - syncCaptureState() - autoOpenChatForExistingHistoryIfNeeded() - // Post-onboarding, the resting hub is shown by default — open the chat - // surface so the personalized opener (set on onboarding completion) is - // actually visible instead of hidden behind the hub. - if chatProvider.onboardingOpener != nil { openHomeChat(focusInput: false) } - consumePendingMainChatOpenRequest() - reportHomeAutomationMode() - intelligenceStore.setRecommendationActionHandler { recommendation in - await openRecommendation(recommendation) - } - intelligenceStore.registerAutomationActions() - Task { await intelligenceStore.load() } - Task { - if let recommendationID = ContextualTaskNavigationRouter.shared.consume() { - _ = await intelligenceStore.openRecommendation(id: recommendationID) - } - } - Task { await homeStatusStore.refreshIfNeeded() } - Task { await homeSuggestionsStore.refreshIfNeeded() } - } - .onDisappear { - intelligenceStore.setRecommendationActionHandler(nil) - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - viewModel.refreshGoals() - Task { await intelligenceStore.load() } - appState.checkAllPermissions() - syncCaptureState() - Task { await homeStatusStore.refreshIfNeeded() } - Task { await homeSuggestionsStore.refreshIfNeeded() } - } - .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .whatMattersNowContextDidRefresh)) { notification in - guard let projection = notification.object as? OmiAPI.WhatMattersNowProjection else { return } - intelligenceStore.applyContextProjection(projection) - } - .onReceive(NotificationCenter.default.publisher(for: .openWhatMattersNowRecommendation)) { notification in - guard - let recommendationID = notification.userInfo?[ - TaskContextualResurfacingService.recommendationIDUserInfoKey - ] as? String - else { return } - guard ContextualTaskNavigationRouter.shared.consume(requestedID: recommendationID) != nil else { return } - Task { _ = await intelligenceStore.openRecommendation(id: recommendationID) } - } - .onReceive(NotificationCenter.default.publisher(for: .screenCapturePermissionLost)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .screenCaptureKitBroken)) { _ in - syncCaptureState() - } - } - - private func applyHomeStageObservers<Content: View>(to content: Content) -> some View { - content - // "Continue in Omi" while the dashboard is already mounted; the - // not-yet-mounted case is covered by the consume in onAppear. - .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in - consumePendingMainChatOpenRequest() - } - // Chat history is the home surface: as soon as the (async) history - // load shows prior messages, land on the chat panel, not the greeting. - .onChange(of: chatProvider.messages.count) { _, _ in - autoOpenChatForExistingHistoryIfNeeded() - } - // The journal projection is installed before the initial-load flag is - // cleared. Observe the flag as well so Home reveals the atomic snapshot - // only after restoration is complete. - .onChange(of: chatProvider.isLoading) { _, _ in - autoOpenChatForExistingHistoryIfNeeded() - } - // Clicking into the ask bar reveals the inline chat; the same is true - // when focus lands there via keyboard (Tab / Full Keyboard Access). - .onChange(of: homeAskFieldFocused) { _, focused in - if focused && !useLegacyHomeDesign && homeMode != .chat { - openHomeChat() - } - } - // Automation-bridge entry points (home_open_chat / home_connect_toggle / - // home_close_panel / home_ask) — they call the exact functions the - // on-screen controls call. - .onReceive(NotificationCenter.default.publisher(for: .homeStageOpenChat)) { _ in - guard !useLegacyHomeDesign else { return } - openHomeChat() - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageToggleConnect)) { _ in - guard !useLegacyHomeDesign else { return } - toggleHomeConnectPanel() - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageClose)) { _ in - guard !useLegacyHomeDesign else { return } - collapseHomeStagePanel() - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageAsk)) { note in - guard !useLegacyHomeDesign, - let query = note.userInfo?["query"] as? String - else { return } - askHomeSuggestion(query) - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageAttach)) { note in - guard !useLegacyHomeDesign, - let path = note.userInfo?["path"] as? String - else { return } - // Same wiring the ask bar's paperclip/drag-drop runs after the - // OS hands back file URLs. - if let attachment = ChatAttachment.from(url: URL(fileURLWithPath: path)) { - chatProvider.addAttachments([attachment]) - } - } - } - - private var legacyHome: some View { - VStack(spacing: 0) { - dashboardWidgets - - ChatMessagesView( - messages: chatProvider.messages, - conversationIdentity: chatProvider.currentSessionId ?? ChatConversationIdentity.mainChatDefault, - isSending: chatProvider.isSending, - hasMoreMessages: chatProvider.hasMoreMessages, - isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, - isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing, - app: selectedApp, - onLoadMore: { await chatProvider.loadMoreMessages() }, - onRate: { messageId, rating, reason in - Task { await chatProvider.rateMessage(messageId, rating: rating, reason: reason) } - }, - onCitationTap: { citation in - handleCitationTap(citation) - }, - sessionsLoadError: chatProvider.sessionsLoadError.map { - UserFacingErrorPresentation.message(from: $0, while: .chatSessions) - }, - onRetry: { Task { await chatProvider.retryLoad() } }, - localSendToken: chatProvider.localSendToken, - onOpenAgent: { agentID, completion in - FloatingControlBarManager.shared.openAgentChatFromTimeline(agentID: agentID, completion: completion) - }, - onOpenAgentRef: FloatingControlBarManager.shared.openAgentChatFromTimeline(ref:completion:), - chatFirstRichBlockContext: chatFirstRichBlockContext, - welcomeContent: { dashboardChatWelcome } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .mask( - LinearGradient( - stops: [ - .init(color: .clear, location: 0.0), - .init(color: .black, location: 0.08), - .init(color: .black, location: 0.92), - .init(color: .clear, location: 1.0), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - - dashboardChatErrorCard - .padding(.horizontal, OmiSpacing.section) - - ChatDraftScope(draft: chatProvider.composerDraft) { draft in - ChatInputView( - onSend: { text in - Task { - await chatProvider.sendMainDraft( - text, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: text.count, - hasSelectedAppContext: selectedApp != nil, - source: "dashboard_chat" - ) - }) - } - }, - onStop: { - chatProvider.stopAgent(owner: .mainChat) - }, - isSending: chatProvider.isSending, - isStopping: chatProvider.isStopping, - placeholder: "Ask omi anything", - mode: $chatProvider.chatMode, - inputText: draft, - attachments: $chatProvider.pendingAttachments, - onAttachmentsAdded: { urls in - let toAdd = urls.compactMap { ChatAttachment.from(url: $0) } - chatProvider.addAttachments(toAdd) - }, - onAttachmentRemoved: { id in - chatProvider.removePendingAttachment(id: id) - }, - references: chatProvider.pendingComposerReferences, - onReferenceRemoved: { id in - chatProvider.removeComposerReference(id: id) - } - ) - .padding(.horizontal, OmiSpacing.section) - .padding(.top, OmiSpacing.md) - .padding(.bottom, OmiSpacing.xl) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.clear) - } - - // MARK: - Redesigned Home - - private var redesignedHome: some View { - GeometryReader { proxy in - let panelHeight = min(max(proxy.size.height - 132, CGFloat(440)), CGFloat(640)) - let panelTop = max(CGFloat(82), (proxy.size.height - panelHeight) / 2) - let panelWidth = homeStageContentWidth(for: proxy.size.width) - - // No canvas of its own. The window has no ground at all (`ShellWindowChrome`) and Home's - // own panels are the glass; Home used to paint a near-black gradient edge to edge, which - // survived the palette conversion and left every `Ink` colour on the page — all of - // which resolve *dark* on the light-pinned panel — drawn near-black on near-black. - ZStack(alignment: .topTrailing) { - // Clicking anywhere outside the chat / connect panel collapses - // back to the resting surface (panels and the ask bar consume their - // own clicks above this catcher). When chat history exists, chat IS - // the resting Home surface, so no catcher is mounted over it — and - // the hub is never an overlay, so no catcher is ever mounted over - // the hub either (a stray click must not throw the user into chat). - if HomeStageMode.collapseCatcherActive(mode: homeMode, resting: homeRestingMode) { - Color.black.opacity(0.001) - .ignoresSafeArea() - .contentShape(Rectangle()) - .onTapGesture { - collapseHomeStagePanel() - } - } - - homeStage(stageWidth: proxy.size.width, stageHeight: proxy.size.height) - .frame(width: proxy.size.width, height: proxy.size.height) - // The popup/sheet overlays are modal: while one is up, the - // stage underneath must not be reachable by VoiceOver / - // Full Keyboard Access. - .accessibilityHidden(isHomeModalPresented) - - // Capture/Listening now live in the shell's constant top bar (see - // DesktopTopBar), so the home no longer renders its own header copy. - - homeConnectSheetOverlay( - contentWidth: proxy.size.width, - panelWidth: panelWidth, - panelHeight: panelHeight, - panelTop: panelTop - ) - - // Esc collapses the connect tray (and, with no chat history, the - // inline chat) back to the resting surface — but only while no modal - // overlay owns the key. Chat with history is Home itself and cannot - // be escaped; the hub is likewise never escaped *into* a panel. - if HomeStageMode.collapseCatcherActive(mode: homeMode, resting: homeRestingMode) - && !isHomeModalPresented - { - OverlayModalEscapeCatcher { - collapseHomeStagePanel() - } - } - } - .omiAnimation(.easeOut(duration: 0.2), value: homeConnectSheetIsPresented) - .omiAnimation(Self.homeStageAnimation, value: homeMode) - } - } - - /// Vertical stage: mode content on top (hub metrics, inline chat, or the - /// connect tray), the persistent ask bar anchored beneath it, and the - /// suggested questions under the bar while the hub is showing. - private func homeStage(stageWidth: CGFloat, stageHeight: CGFloat) -> some View { - Group { - if homeMode == .hub { - homeHubStage(stageWidth: stageWidth) - .transition(.homeHubStage) - } else { - homePanelStage(stageWidth: stageWidth, askBarWidth: homeChatColumnWidth(for: stageWidth)) - } - } - .padding(.top, homeMode.topPadding(hub: Self.homeStageTopPadding)) - .padding(.bottom, Self.homeStageBottomPadding) - } - - /// Hub layout: the greeting headline and knows-list rows centered on the - /// stage over the memory constellation, with the goals/error surfaces and - /// the ask bar docked as one column at the bottom. - private func homeHubStage(stageWidth: CGFloat) -> some View { - // Keep the knows-list column tight so short rows (e.g. "Call Rabia") don't - // strand their trailing icon across a wide gap; long one-liners still fit. - let columnWidth = min(CGFloat(520), homeStageContentWidth(for: stageWidth)) - - return VStack(spacing: 0) { - Spacer(minLength: 0) - - homeHubHeadline - .transition(.homeHubFade) - - homeKnowsList(width: columnWidth) - .padding(.top, OmiSpacing.xxl) - .transition(.homeSuggestionsFade) - - Spacer(minLength: 0) - - // Only this column's width tracks the typed text, so only it subscribes. - ChatDraftScope(draft: chatProvider.composerDraft) { draft in - let askBarWidth = homeHubAskBarWidth(for: stageWidth, draft: draft.wrappedValue) - VStack(spacing: 0) { - dashboardIntelligenceError - .frame(width: askBarWidth) - .padding(.bottom, intelligenceStore.error == nil ? 0 : OmiSpacing.sm) - - FocusedGoalsSection( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onShowAll: { showingAllGoals = true } - ) - .frame(width: askBarWidth) - .padding(.bottom, hasFocusedGoalsSurface ? OmiSpacing.md : 0) - - homeAskBar - .frame(width: askBarWidth) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var hasFocusedGoalsSurface: Bool { - !intelligenceStore.focusedGoals.isEmpty || intelligenceStore.accountGeneration != nil - } - - /// Panel layout (chat / connect): the surface fills the height with the ask - /// bar anchored directly beneath it. - private func homePanelStage(stageWidth: CGFloat, askBarWidth: CGFloat) -> some View { - VStack(spacing: 0) { - ZStack { - switch homeMode { - case .chat: - homeChatPanel(width: askBarWidth) - .transition(.homeChatRise) - case .connect: - homeConnectPanel(stageWidth: stageWidth) - .transition(.homeDropFromTop) - case .hub: - EmptyView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - // Rolling suggestions sit just above the ask bar while the chat is empty — - // but not for a just-onboarded user, whose empty chat shows the personalized - // onboarding opener (with its own starter questions) instead. - if chatProvider.messages.isEmpty && chatProvider.onboardingOpener == nil { - homeRollingSuggestions - .frame(width: askBarWidth) - .padding(.bottom, OmiSpacing.sm) - } - - homeAskBar - .frame(width: askBarWidth) - .padding(.top, OmiSpacing.xxs) - - dashboardChatErrorCard - .frame(width: askBarWidth) - .padding(.top, OmiSpacing.sm) - } - } - - /// A small, auto-rotating set of prompt suggestions shown above the ask bar on - /// an empty home chat — replaces the old greeting hero + knows-list cards. - private var homeRollingSuggestions: some View { - VStack(spacing: OmiSpacing.xs) { - ForEach(Array(homeKnowsRows.prefix(Self.rollingSuggestionCount))) { row in - Button { - openKnowsRow(row) - } label: { - HStack(spacing: OmiSpacing.sm) { - Image(systemName: rollingSuggestionIcon(row.kind)) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - Text(row.text) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - Spacer(minLength: 8) - } - .padding(.horizontal, OmiSpacing.md) - .frame(height: 34) - .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Ink.rowFill)) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 11)) - } - .buttonStyle(.plain) - .transition(.opacity) - } - } - .omiAnimation(.easeInOut(duration: 0.45), value: knowsRotation) - .onReceive(knowsRotationTimer) { _ in - guard homeMode == .chat, chatProvider.messages.isEmpty, !chatProvider.isSending, homeKnowsCanRotate - else { return } - knowsRotation += 1 - } - .onAppear { beginKnowsVisit(visibleRows: Self.rollingSuggestionCount) } - .onChange(of: knowsImpressionSignature) { _, _ in - recordKnowsImpressions(visibleRows: Self.rollingSuggestionCount) - } - } - - /// The chat-mode strip is shorter than the hub list; impressions are recorded - /// against what is actually on screen, not the full composition. - private static let rollingSuggestionCount = 3 - - private func rollingSuggestionIcon(_ kind: HomeKnowsRowKind) -> String { - switch kind { - case .task: return "circle" - case .insight: return ProactiveNotificationBadge.insightSystemImage - case .question: return "bubble.left" - } - } - - // MARK: Hub centerpiece - - private var homeHubHeadline: some View { - VStack(spacing: OmiSpacing.sm) { - SBLogo(size: 40, spinning: chatProvider.isSending) - .padding(.bottom, OmiSpacing.lg) - - Text(homeHubGreeting) - .scaledFont(size: OmiType.hero, weight: .bold) - .foregroundStyle(HomePalette.ink) - .multilineTextAlignment(.center) - - Text(homeDailyBrief) - .scaledFont(size: OmiType.subheading) - .foregroundStyle(HomePalette.muted) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .center) - } - - private var homeHubGreeting: String { - let name = AuthService.shared.givenName.trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? "I'm ready." : "Hey \(name). I'm ready." - } - - // MARK: Knows list - - /// Insight rows for the hub: the task-intelligence recommendations plus the - /// learned insights ("things about you") from the Insights store, so the hub - /// surfaces insights, not only tasks and asks. - private var homeKnowsInsightCandidates: [HomeKnowsInsightCandidate] { - let recommendations = intelligenceStore.recommendations.map { - HomeKnowsInsightCandidate(id: $0.id, text: $0.headline) - } - let learned = insightStorage.insightHistory - .filter { !$0.isDismissed } - .prefix(12) - .map { HomeKnowsInsightCandidate(id: $0.id, text: $0.insight.insight, updatedAt: $0.createdAt) } - return recommendations + Array(learned) - } - - private var homeKnowsComposition: HomeKnowsComposition { - HomeKnowsListComposer.compose( - tasks: homeKnowsTaskCandidates, - insights: homeKnowsInsightCandidates, - tip: homeActionTip, - questions: homeSuggestedQuestions, - ledger: knowsLedger, - rotation: knowsRotation - ) - } - - private var homeKnowsRows: [HomeKnowsRow] { homeKnowsComposition.rows } - - /// True when more candidates still qualify than the hub shows, so rotating - /// cycles to genuinely different rows instead of the same set. - private var homeKnowsCanRotate: Bool { homeKnowsComposition.canRotate } - - /// A composed, high-agency nudge for the tip slot when there's no server - /// insight — one thing you can hand Omi with a tap (it prefills the chat). - private var homeActionTip: String? { - if homeOpenTaskCount >= 5 { - return "Sort my open tasks — which 3 actually matter today?" - } - return "Recap what I got done today" - } - - /// Open, undismissed tasks. Read from the ledger so the greeting and the rows - /// agree about what the reader has already waved off. - private var homeOpenTaskCount: Int { - HomeKnowsListComposer.openTaskCount(homeKnowsTaskCandidates, ledger: knowsLedger) - } - - /// A short, conversational read on the day — what you've been doing and how - /// much is waiting — shown under the greeting. It absorbs the focus status so - /// the action rows below stay purely actionable. - private var homeDailyBrief: String { - let openCount = homeOpenTaskCount - let tail: String - switch openCount { - case 0: tail = "nothing's waiting on you." - case 1: tail = "one thing needs you." - default: tail = "\(openCount) things need you." - } - - return tail.prefix(1).uppercased() + tail.dropFirst() - } - - /// Lifecycle and freshness travel with the candidate: the composer owns the - /// completed/deleted and past-due exclusions so one place decides what a row - /// is allowed to be. - private var homeKnowsTaskCandidates: [HomeKnowsTaskCandidate] { - (viewModel.overdueTasks + viewModel.todaysTasks + viewModel.recentTasks) - .map { - HomeKnowsTaskCandidate( - id: $0.id, - text: $0.description, - dueAt: $0.dueAt, - updatedAt: $0.updatedAt, - isActive: !$0.completed && !$0.isRetired) - } - } - - private func homeKnowsList(width: CGFloat) -> some View { - VStack(spacing: OmiSpacing.sm) { - ForEach(homeKnowsRows) { row in - HomeKnowsRowView( - row: row, - onOpen: { openKnowsRow(row) }, - onDismiss: knowsDismissHandler(for: row), - onLater: knowsLaterHandler(for: row) - ) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .frame(width: width) - .omiAnimation(.easeInOut(duration: 0.45), value: knowsRotation) - .onReceive(knowsRotationTimer) { _ in - // Only rotate on the resting hub, when idle, and when there's genuinely - // more to show — so the set feels alive without churning under you. - guard homeMode == .hub, !chatProvider.isSending, homeKnowsCanRotate else { return } - knowsRotation += 1 - } - .onAppear { beginKnowsVisit() } - .onChange(of: knowsImpressionSignature) { _, _ in recordKnowsImpressions() } - .accessibilityIdentifier("home-knows-list") - } - - /// Starts a visit to the knows-list: re-reads the ledger (so an account - /// switch cannot inherit the previous owner's history) and resets the - /// once-per-visit impression de-duplication. - private func beginKnowsVisit(visibleRows: Int = HomeKnowsListComposer.maxRows) { - knowsLedgerStore.beginVisit() - knowsLedger = knowsLedgerStore.snapshot() - knowsRotation = 0 - recordKnowsImpressions(visibleRows: visibleRows) - } - - /// Identity of what is currently on screen. Changes when the rotation timer - /// advances or the candidate sources change — the moments a new impression - /// genuinely happened. - private var knowsImpressionSignature: [String] { - let composition = homeKnowsComposition - return composition.rows.map(\.ledgerKey) - + composition.emptySlots.map { "\($0.slot.rawValue)=\($0.reason.rawValue)" } - } - - /// Hands every visible row back to the ledger and reports it once per visit. - private func recordKnowsImpressions(visibleRows: Int = HomeKnowsListComposer.maxRows) { - let composition = homeKnowsComposition - for row in composition.rows.prefix(visibleRows) { - guard - let impression = knowsLedgerStore.recordShown( - key: row.ledgerKey, contentHash: row.contentHash) - else { continue } - AnalyticsManager.shared.trackHomeKnowsRowShown( - kind: row.kind.analyticsKind, - slot: slot(for: row, in: composition), - showsBefore: max(0, impression.shows - 1)) - } - for empty in composition.emptySlots { - guard knowsLedgerStore.shouldReportEmptySlot(empty.slot.rawValue) else { continue } - AnalyticsManager.shared.trackHomeKnowsSlotEmpty(slot: empty.slot, reason: empty.reason) - } - } - - /// Which typed slot a rendered row landed in — the filled slots are whatever - /// the empty ones are not, in the composer's fixed order. - private func slot(for row: HomeKnowsRow, in composition: HomeKnowsComposition) -> HomeKnowsSlot { - let empty = Set(composition.emptySlots.map(\.slot)) - let filled = HomeKnowsSlot.allCases.filter { !empty.contains($0) } - guard let index = composition.rows.firstIndex(where: { $0.id == row.id }), - index < filled.count - else { return .ask } - return filled[index] - } - - private func openKnowsRow(_ row: HomeKnowsRow) { - knowsLedger.entries[row.ledgerKey] = knowsLedgerStore.recordOpened( - key: row.ledgerKey, contentHash: row.contentHash) - switch row.kind { - case .task(let id): - if let task = (viewModel.overdueTasks + viewModel.todaysTasks + viewModel.recentTasks) - .first(where: { $0.id == id }) - { - TaskNavigationRequestStore.shared.request(task: task) - } - navigate(to: .tasks) - case .insight(let id): - guard let recommendation = intelligenceStore.recommendations.first(where: { $0.id == id }) - else { return } - Task { - if await openRecommendation(recommendation) { - await intelligenceStore.recordPrimaryAction(recommendation) - } - } - case .question: - // Prefill the ask bar so you can glance it over and edit before sending, - // rather than firing the suggestion blindly. - chatProvider.draftText = row.text - homeAskFieldFocused = true - } - } - - private func knowsDismissHandler(for row: HomeKnowsRow) -> ((OmiAPI.TaskIntelligenceFeedbackReason?) -> Void)? { - switch row.kind { - case .task: - return { _ in recordKnowsDismiss(row) } - case .insight(let id): - return { reason in - // Learned insights have no server recommendation behind them; the - // ledger is what makes their dismissal stick either way. - recordKnowsDismiss(row) - guard let recommendation = intelligenceStore.recommendations.first(where: { $0.id == id }) - else { return } - Task { await intelligenceStore.dismiss(recommendation, reason: reason) } - } - case .question: - return nil - } - } - - /// A dismissed row never returns unless its underlying object changes. - private func recordKnowsDismiss(_ row: HomeKnowsRow) { - knowsLedger.entries[row.ledgerKey] = knowsLedgerStore.recordDismissed( - key: row.ledgerKey, contentHash: row.contentHash) - } - - private func knowsLaterHandler(for row: HomeKnowsRow) -> (() -> Void)? { - guard case .insight(let id) = row.kind else { return nil } - return { - guard let recommendation = intelligenceStore.recommendations.first(where: { $0.id == id }) - else { return } - Task { await intelligenceStore.later(recommendation) } - } - } - - // MARK: Inline chat panel - - private func homeChatPanel(width: CGFloat) -> some View { - VStack(spacing: 0) { - ChatMessagesView( - messages: chatProvider.messages, - conversationIdentity: chatProvider.currentSessionId ?? ChatConversationIdentity.mainChatDefault, - isSending: chatProvider.isSending, - hasMoreMessages: chatProvider.hasMoreMessages, - isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, - isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing, - app: selectedApp, - onLoadMore: { await chatProvider.loadMoreMessages() }, - onRate: { messageId, rating, reason in - Task { await chatProvider.rateMessage(messageId, rating: rating, reason: reason) } - }, - onCitationTap: { citation in - handleCitationTap(citation) - }, - sessionsLoadError: chatProvider.sessionsLoadError.map { - UserFacingErrorPresentation.message(from: $0, while: .chatSessions) - }, - onRetry: { Task { await chatProvider.retryLoad() } }, - localSendToken: chatProvider.localSendToken, - onCancelTurn: { chatProvider.stopAgent(owner: .mainChat) }, - onOpenAgent: { agentID, completion in - FloatingControlBarManager.shared.openAgentChatFromTimeline(agentID: agentID, completion: completion) - }, - onOpenAgentRef: { ref, completion in - FloatingControlBarManager.shared.openAgentChatFromTimeline(ref: ref, completion: completion) - }, - horizontalContentPadding: 0, - chatFirstRichBlockContext: chatFirstRichBlockContext, - verticalContentPadding: OmiSpacing.sm, - trailingContentPadding: OmiSpacing.md, - welcomeContent: { dashboardChatWelcome } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { reportChatFirstTranscriptPageIfReady() } - .onChange(of: chatProvider.isMainChatJournalFirstPageReady) { _, _ in - reportChatFirstTranscriptPageIfReady() - } - .onDisappear { - didReportChatFirstTranscriptPage = false - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptDidDisappear() - } - // The composer already has its own visual boundary. Masking this viewport - // fades the live edge and can cut off the first lines of an incoming reply. - .padding(.bottom, OmiSpacing.xs) - - } - // Chat is the Home surface itself — no card chrome, it sits directly on - // the ambient canvas. The column matches the ask bar's width exactly so - // message edges align with the bar's edges. - .frame(width: width) - } - - private func reportChatFirstTranscriptPageIfReady() { - guard !didReportChatFirstTranscriptPage, - chatFirstRichBlockContext != nil, - chatProvider.isMainChatJournalFirstPageReady - else { return } - didReportChatFirstTranscriptPage = true - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() - } - - // MARK: Connect tray - - private func homeConnectPanel(stageWidth: CGFloat) -> some View { - // Sources feed omi; omi's memory flows out to the AI destinations — - // the chevron between the two cards reads that direction. The tray - // hugs its content: no scroll filler below the columns. - HStack(alignment: .center, spacing: OmiSpacing.md) { - homeConnectColumnCard { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - sourceColumnHeader - sourceConstellation - } - } - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.body, weight: .bold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 30, height: 30) - .background(Circle().fill(HomePalette.tile)) - .overlay(Circle().stroke(HomePalette.hairline, lineWidth: 1)) - .accessibilityHidden(true) - - homeConnectColumnCard { - destinationStack - } - } - .padding(OmiSpacing.lg) - .background( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(Ink.rowFillHover) - ) - .overlay( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .overlay(alignment: .topTrailing) { - HomeIconActionButton(title: "Close connect", systemImage: "xmark") { - collapseHomeStagePanel() - } - .padding(OmiSpacing.md) - } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) - .frame(width: homeStagePanelWidth(for: stageWidth)) - } - - private func homeConnectColumnCard<Content: View>(@ViewBuilder content: () -> Content) -> some View { - content() - .padding(OmiSpacing.lg) - .frame(maxWidth: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - } - - // MARK: Ask bar + suggestions - - @ViewBuilder - private var dashboardChatErrorCard: some View { - if let cardState = chatProvider.currentError { - ChatErrorCard( - state: cardState, - onRecover: { - Task { await chatProvider.recoverFromError() } - }, - onDismiss: { - chatProvider.dismissCurrentError() - } - ) - } - } - - private var homeAskBar: some View { - ChatDraftScope(draft: chatProvider.composerDraft) { draft in - HomeAskBar( - text: draft, - isSending: chatProvider.isSending, - isStopping: chatProvider.isStopping, - isConnectActive: homeMode == .connect, - focus: $homeAskFieldFocused, - attachments: $chatProvider.pendingAttachments, - onAttachmentsAdded: { urls in - let toAdd = urls.compactMap { ChatAttachment.from(url: $0) } - chatProvider.addAttachments(toAdd) - }, - onAttachmentRemoved: { id in - chatProvider.removePendingAttachment(id: id) - }, - onSend: sendFromHomeAskBar, - onStop: { chatProvider.stopAgent(owner: .mainChat) }, - onConnect: toggleHomeConnectPanel, - // Tapping the bar begins a fresh chat and focuses it to type, staying on - // the hero; only sending enters the chat surface (see sendFromHomeAskBar). - onActivate: { focusHomeAskBar() } - ) - } - } - - private var homeSuggestedQuestions: [String] { - HomeSuggestionComposer.compose( - personalized: homeSuggestionsStore.personalizedQuestions, - onboarding: PostOnboardingPromptSuggestions.suggestions(), - dayZero: .live() - ) - } - - private func homeStageSideInset(for stageWidth: CGFloat) -> CGFloat { - min(Self.homeStageMaxSideInset, max(Self.homeStageMinSideInset, stageWidth * 0.06)) - } - - private func homeStageContentWidth(for stageWidth: CGFloat) -> CGFloat { - let sideInset = homeStageSideInset(for: stageWidth) - return min(Self.homeStageMaxWidth, max(CGFloat(0), stageWidth - (sideInset * 2))) - } - - private func homeStagePanelWidth(for stageWidth: CGFloat) -> CGFloat { - min(Self.homeStagePanelMaxWidth, homeStageContentWidth(for: stageWidth)) - } - - /// Chat mode: bar and message column share one readable width. Draft-independent. - private func homeChatColumnWidth(for stageWidth: CGFloat) -> CGFloat { - min(Self.homeChatColumnMaxWidth, homeStageContentWidth(for: stageWidth)) - } - - /// Hub mode: the resting bar grows to fit what has been typed. - private func homeHubAskBarWidth(for stageWidth: CGFloat, draft: String) -> CGFloat { - let availableWidth = min(Self.homeAskBarMaxWidth, homeStageContentWidth(for: stageWidth)) - let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return min(availableWidth, Self.homeAskBarMinWidth) } - let measuredTextWidth = (text as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: 15)]).width - // Paperclip + mic + Send/Connect + the bar's own padding. The mic joined the - // leading cluster after this was first measured; a stale value here crops the - // typed text instead of growing the bar. - let chromeWidth: CGFloat = 252 - return min(availableWidth, max(Self.homeAskBarMinWidth, measuredTextWidth + chromeWidth)) - } - - // MARK: Stage actions - - private func reportHomeAutomationMode() { - guard DesktopAutomationLaunchOptions.isEnabled else { return } - let modeLabel = useLegacyHomeDesign ? nil : homeMode.automationLabel - _ = DesktopAutomationStateStore.shared.updateLiveFields { snapshot in - snapshot.homeMode = modeLabel - snapshot.updatedAt = ISO8601DateFormatter().string(from: Date()) - } - } - - /// Keep the useful insights hub visible while the canonical journal restores. - /// Once the atomic snapshot is ready, existing history becomes Home without - /// exposing the generic transcript loading spinner. - private func autoOpenChatForExistingHistoryIfNeeded() { - guard - HomeHistoryPresentationPolicy.restingMode( - isLoading: chatProvider.isLoading, - messageCount: chatProvider.messages.count - ) == .chat, - homeMode == .hub, - chatProvider.onboardingOpener == nil - else { return } - openHomeChat(focusInput: false) - } - - /// Floating-bar "Continue in Omi": land directly on the chat panel instead - /// of whatever surface Home was resting on. - private func consumePendingMainChatOpenRequest() { - guard MainChatNavigationRequestStore.shared.consume() else { return } - let draft = MainChatNavigationRequestStore.shared.consumeDraft() - if let draft { - // Prefill, focus, and stop: the user reads the suggestion and decides to send it. - chatProvider.draftText = draft - homeAskFieldFocused = true - } - guard !useLegacyHomeDesign else { return } - openHomeChat(focusInput: draft == nil) - } - private func openHomeChat(focusInput: Bool = true) { - if let onOpenPrimaryChat { - onOpenPrimaryChat() - return - } - if homeMode != .chat { - OmiMotion.withGated(Self.homeStageAnimation) { - homeMode = .chat - } - } - if focusInput { - focusHomeAskFieldAfterStageTransition() - } - reportHomeAutomationMode() - } - - private func focusHomeAskFieldAfterStageTransition() { - let token = homeAskFocusPolicy.currentToken() - Task { @MainActor in - await Task.yield() - // A deferred focus is stale once anything connects / collapses / closes - // (each bumps the policy's generation), and must never land on a non-chat - // stage — both would route back through the focus observer into chat. - guard homeAskFocusPolicy.isCurrent(token), homeMode == .chat else { return } - homeAskFieldFocused = true - } - } - - /// The surface Home rests on when no panel is explicitly open: the chat - /// timeline once any history exists, otherwise the greeting hub. - /// Home opens directly in the continuous chat (no greeting hero). Rolling - /// suggestions sit above the ask bar while the chat is empty. - private var homeRestingMode: HomeStageMode { - HomeHistoryPresentationPolicy.restingMode( - isLoading: chatProvider.isLoading, - messageCount: chatProvider.messages.count - ) - } - - /// User-facing collapse (click outside, Esc, connect ×) and the automation - /// bridge's `home_close_panel`: returns to the resting surface. There is a - /// single close path now — the bridge no longer force-jumps to the hub. - private func collapseHomeStagePanel() { - homeAskFieldFocused = false - homeAskFocusPolicy.invalidate() - OmiMotion.withGated(Self.homeStageAnimation) { - homeMode = homeRestingMode - } - reportHomeAutomationMode() - } - - private func toggleHomeConnectPanel() { - homeAskFocusPolicy.invalidate() - let target: HomeStageMode = homeMode == .connect ? homeRestingMode : .connect - if target == .connect { - homeAskFieldFocused = false - } - OmiMotion.withGated(Self.homeStageAnimation) { - homeMode = target - } - reportHomeAutomationMode() - } - - /// Omi is one continuous chat — tapping the ask bar just focuses it to type, - /// continuing the single thread (no new sessions, no history). - private func focusHomeAskBar() { - homeAskFieldFocused = true - } - - private func sendFromHomeAskBar() { - let draft = chatProvider.draftText - let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - // Text is required — ChatProvider.sendMessage no-ops on empty text, so - // an attachment-only "send" would silently drop the turn. - guard !text.isEmpty else { return } - if let onOpenPrimaryChat { - onOpenPrimaryChat() - guard !chatProvider.isSending else { return } - Task { - await chatProvider.sendMainDraft( - draft, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: text.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_ask_bar" - ) - }) - } - return - } - openHomeChat(focusInput: false) - if !chatProvider.isSending { - Task { - await chatProvider.sendMainDraft( - draft, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: text.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_ask_bar" - ) - }) - } - } - } - - private func askHomeSuggestion(_ suggestion: String) { - if DayZeroChips.isDraftPrompt(suggestion) { - // "Remember that I…" is a sentence for the user to finish, not a question to fire. - chatProvider.draftText = DayZeroChips.draftText(for: suggestion) - if onOpenPrimaryChat == nil { openHomeChat(focusInput: true) } - homeAskFieldFocused = true - return - } - if let onOpenPrimaryChat { - onOpenPrimaryChat() - guard !chatProvider.isSending else { return } - Task { - _ = await chatProvider.sendMessage( - suggestion, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: suggestion.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_suggested_question" - ) - }) - } - return - } - openHomeChat(focusInput: false) - Task { - _ = await chatProvider.sendMessage( - suggestion, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: suggestion.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_suggested_question" - ) - }) - } - } - - @ViewBuilder - private func homeConnectSheetOverlay( - contentWidth: CGFloat, - panelWidth: CGFloat, - panelHeight: CGFloat, - panelTop: CGFloat - ) -> some View { - ZStack { - if homeConnectSheetIsPresented { - // Same lane as the apps popup above it, for the same reason. - ShellModalScrim(onTap: dismissHomeConnectSheet) - .transition(.opacity) - .zIndex(4) - - let sheetSize = homeConnectSheetSize(panelWidth: panelWidth, panelHeight: panelHeight) - - homeConnectSheetContent() - .frame(width: sheetSize.width, height: sheetSize.height) - // Same as the apps popup: a bounded card is its own surface. - .shellModalScrimBounds(.ownSurface) - .background(Ink.surface) - .clipShape(RoundedRectangle(cornerRadius: Self.homeConnectSheetCornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Self.homeConnectSheetCornerRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) - .position(x: contentWidth / 2, y: panelTop + panelHeight / 2) - .transition(.scale(scale: 0.96).combined(with: .opacity)) - .accessibilityAddTraits(.isModal) - .zIndex(5) - - if homeConnectSheetAcceptsInput { - OverlayModalEscapeCatcher { - dismissHomeConnectSheet() - } - .zIndex(5) - } - } - } - .allowsHitTesting(homeConnectSheetAcceptsInput) - .zIndex(4) - } - - private func homeConnectSheetSize(panelWidth: CGFloat, panelHeight: CGFloat) -> CGSize { - let preferred = homeConnectSheetPreferredSize - return CGSize( - width: min( - preferred.width, - max(Self.homeConnectSheetMinWidth, panelWidth - (Self.homeConnectSheetHorizontalMargin * 2)) - ), - height: min( - preferred.height, - max(Self.homeConnectSheetMinHeight, panelHeight - (Self.homeConnectSheetVerticalMargin * 2)) - ) - ) - } - - private var homeConnectSheetPreferredSize: CGSize { - if selectedCatalogApp != nil { - return Self.appDetailSheetPreferredSize - } - if selectedImportConnector != nil { - return Self.importConnectorSheetPreferredSize - } - return Self.exportDestinationSheetPreferredSize - } - - @ViewBuilder - private func homeConnectSheetContent() -> some View { - if let app = selectedCatalogApp { - AppDetailSheet(app: app, appProvider: appProvider, onDismiss: { dismissHomeConnectSheet() }) - .onAppear { - AnalyticsManager.shared.appDetailViewed(appId: app.id, appName: app.name) - } - } else if let connector = selectedImportConnector { - ImportConnectorSheet( - connector: connector, - appState: appState, - statusStore: homeStatusStore.connectorStatusStore, - onDismiss: { - dismissHomeConnectSheet() - } - ) - } else if let destination = selectedExportDestination { - ConnectDestinationSheet( - destination: destination, - statuses: $homeStatusStore.memoryExportStatuses, - onDismiss: { - dismissHomeConnectSheet() - } - ) - } - } - - private var homeHeader: some View { - let transcriptionUnavailable = appState.transcriptionServiceError != nil - - return HStack { - Spacer() - HStack(spacing: OmiSpacing.sm) { - HomeStatusButton( - title: "Capture", - systemImage: "viewfinder", - status: captureStatus, - isToggling: isTogglingCapture, - action: toggleCapture - ) - // Rewind isn't a top-level tab; it opens from a right-click on Capture. - .contextMenu { - Button { - navigate(to: .rewind) - } label: { - Label("Open Rewind", systemImage: "clock.arrow.circlepath") - } - } - - HomeListeningStatusButton( - title: transcriptionUnavailable ? "Transcription unavailable" : "Listening", - systemImage: transcriptionUnavailable - ? "exclamationmark.triangle.fill" - : (appState.isLiveCapturing ? "waveform.circle.fill" : "mic.circle"), - status: CaptureListeningLogic.listeningStatus(appState: appState), - modeTitle: listeningModeTitle, - isAwaitingMeeting: appState.isAwaitingMeeting, - isToggling: isTogglingListening, - action: toggleListening - ) - // Settings lives in the nav rail (bottom-left) — no duplicate gear here. - } - } - .frame(height: 36) - } - - private var sourceColumnHeader: some View { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Connect data") - .font(.system(size: 20, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - - Text("Sources Omi learns from.") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - } - - private var sourceConstellation: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - HomeAIChoiceButton(title: "Gmail", brand: .gmail, isConnected: isImportConnectorConnected("email")) { - openImportConnector("email") - } - HomeAIChoiceButton(title: "Calendar", brand: .calendar, isConnected: isImportConnectorConnected("calendar")) { - openImportConnector("calendar") - } - HomeAIChoiceButton(title: "Files", brand: .localFiles, isConnected: isImportConnectorConnected("local-files")) { - openImportConnector("local-files") - } - HomeAIChoiceButton(title: "Notes", brand: .appleNotes, isConnected: isImportConnectorConnected("apple-notes")) { - openImportConnector("apple-notes") - } - HomeAIChoiceButton(title: "Omi Device", usesOmiMark: true, isConnected: hasOmiDeviceHistory) { - openOmiDeviceWebsite() - } - HomeAIChoiceButton(title: "More", systemImage: "plus") { - openAppsPage() - } - } - } - - private var destinationStack: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Use omi memory anywhere") - .font(.system(size: 20, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - - Text("Bring your memories to the apps you use") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .fixedSize(horizontal: false, vertical: true) - } - - HomeAIChoiceButton(title: "Ask Omi", usesOmiMark: true) { - openHomeChat() - } - HomeAIChoiceButton(title: "Claude / Claude Code", brand: .claude, isConnected: isMCPDestinationConnected(.claude)) - { - openExportDestination(.claudeCode) - } - HomeAIChoiceButton(title: "ChatGPT / Codex", brand: .chatgpt, isConnected: isMCPDestinationConnected(.chatgpt)) { - openExportDestination(.chatgpt) - } - HomeAIChoiceButton(title: "OpenClaw", brand: .openclaw, isConnected: isMCPDestinationConnected(.openclaw)) { - openExportDestination(.openclaw) - } - HomeAIChoiceButton(title: "Hermes", brand: .hermes, isConnected: isMCPDestinationConnected(.hermes)) { - openExportDestination(.hermes) - } - HomeAIChoiceButton(title: "More", systemImage: "plus") { - openAppsPage() - } - } - } - - private func navigate(to item: SidebarNavItem) { - selectedIndex = item.rawValue - AnalyticsManager.shared.tabChanged(tabName: item.title) - } - - private func openAppsPage() { - // The Apps page is the sole catalog owner. Contextual "More" actions clear - // stale filters, then navigate there instead of mounting a bounded copy. - appProvider.clearFilters() - navigate(to: .apps) - } - - private func openImportConnector(_ connectorID: String) { - if let connector = ImportConnector.all.first(where: { $0.id == connectorID }) { - presentImportConnector(connector) - } - } - - private func openExportDestination(_ destination: MemoryExportDestination) { - presentExportDestination(destination) - } - - private func presentCatalogApp(_ app: OmiApp) { - homeConnectSheetAcceptsInput = true - selectedImportConnector = nil - selectedExportDestination = nil - selectedCatalogApp = app - } - - private func presentImportConnector(_ connector: ImportConnector) { - homeConnectSheetAcceptsInput = true - selectedCatalogApp = nil - selectedExportDestination = nil - selectedImportConnector = connector - } - - private func presentExportDestination(_ destination: MemoryExportDestination) { - homeConnectSheetAcceptsInput = true - selectedCatalogApp = nil - selectedImportConnector = nil - selectedExportDestination = destination - } - - private func dismissHomeConnectSheet() { - homeConnectSheetAcceptsInput = false - selectedCatalogApp = nil - selectedImportConnector = nil - selectedExportDestination = nil - } - - private func openOmiDeviceWebsite() { - if let url = URL(string: "https://www.omi.me") { - NSWorkspace.shared.open(url) - } - } - - private func toggleListening() { - CaptureListeningLogic.cycleListening( - appState: appState, audioRecordingModeRaw: $audioRecordingModeRaw, - isTogglingListening: $isTogglingListening) - } - - private func toggleCapture() { - CaptureListeningLogic.toggleCapture( - appState: appState, screenAnalysisEnabled: $screenAnalysisEnabled, - isCaptureMonitoring: $isCaptureMonitoring, isTogglingCapture: $isTogglingCapture) - } - - private func syncCaptureState() { - CaptureListeningLogic.syncCaptureState( - screenAnalysisEnabled: $screenAnalysisEnabled, isCaptureMonitoring: $isCaptureMonitoring) - } - - /// Welcome message shown when there are no chat messages yet. - /// Transparent — no card chrome — so it morphs into the dashboard background. - /// Empty-state of the Home chat: the personalized post-onboarding opener when - /// one is pending (this is where onboarding lands the user), else the default - /// "Ask omi anything" welcome. - @ViewBuilder private var dashboardChatWelcome: some View { - if let opener = chatProvider.onboardingOpener { - OnboardingOpenerView(opener: opener, chatProvider: chatProvider) - } else { - defaultChatWelcome - } - } - - private var defaultChatWelcome: some View { - VStack(spacing: OmiSpacing.md) { - if let logoURL = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png"), - let logoImage = NSImage(contentsOf: logoURL) - { - Image(nsImage: logoImage) - .resizable() - .scaledToFit() - .frame(width: 40, height: 40) - } - - Text("Ask omi anything") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Text("Your personal AI assistant — knows you through your memories and conversations") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, OmiSpacing.page) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.section) - } - - /// Conversation citations use the same root handoff as every other source. - /// The Memory hub owns the only conversation browser/detail presentation. - private func handleCitationTap(_ citation: Citation) { - guard citation.sourceType == .conversation else { - log("Citation tapped: \(citation.title) (memory - no detail view)") - return - } - - ConversationDetailAutomationState.shared.requestOpen( - conversationId: citation.id, - showTranscript: false - ) - NotificationCenter.default.post(name: .desktopAutomationOpenConversationRequested, object: nil) - } - - private func openRecommendation(_ recommendation: DashboardRecommendation) async -> Bool { - switch recommendation.destination { - case .suggested(let candidateID): - guard let candidate = await intelligenceStore.candidateForNavigation(candidateID: candidateID) else { - return false - } - TaskNavigationRequestStore.shared.request(candidate: candidate) - selectedIndex = 4 - return true - case .task(let taskID, let workstreamID): - if let workstreamID { - return await resumeThread(workstreamID: workstreamID, taskID: taskID) - } else { - guard let task = await intelligenceStore.taskForNavigation(taskID: taskID) else { - return false - } - TaskNavigationRequestStore.shared.request(task: task) - selectedIndex = 4 - return true - } - case .thread(let workstreamID, let taskID): - return await resumeThread(workstreamID: workstreamID, taskID: taskID) - case .unavailable: - intelligenceStore.error = "This review target is no longer available." - return false - } - } - - private func openGoal(_ goalID: String) async { - await intelligenceStore.loadGoalDetail(goalID: goalID) - guard intelligenceStore.selectedGoalDetail != nil else { return } - showingAllGoals = false - showingGoalDetail = true - } - - @discardableResult - private func resumeThread(workstreamID: String, taskID: String?) async -> Bool { - guard let taskChatCoordinator else { - intelligenceStore.error = "The task thread is unavailable." - return false - } - if await taskChatCoordinator.openExistingThread( - workstreamID: workstreamID, - preferredTaskID: taskID - ) { - showingGoalDetail = false - showingAllGoals = false - selectedIndex = 4 - return true - } else { - intelligenceStore.error = taskChatCoordinator.errorMessage ?? "The task thread could not be opened." - return false - } - } - - private func startWorkFromSelectedGoal() async { - guard let detail = intelligenceStore.selectedGoalDetail, let taskChatCoordinator else { - intelligenceStore.error = "The goal thread is unavailable." - return - } - do { - let receipt = try await taskChatCoordinator.resolveGoalOrigin( - goalId: detail.goal.goalId, - occurrenceId: "goal-detail-primary-v1", - title: detail.goal.title, - objective: detail.goal.desiredOutcome, - anchorTaskDescription: "Make progress on \(detail.goal.title)" - ) - await resumeThread(workstreamID: receipt.workstreamId, taskID: receipt.taskId) - } catch { - intelligenceStore.error = "Omi could not start work on this goal." - } - } - - // MARK: - Summary counts for collapsed bar - - private var incompleteTaskCount: Int { - viewModel.overdueTasks.count + viewModel.todaysTasks.count + viewModel.recentTasks.count - } - - private var activeGoalCount: Int { - intelligenceStore.accountGeneration == nil - ? viewModel.goals.count - : intelligenceStore.currentGoals.count - } - - // MARK: - Dashboard Widgets (collapsible) - - private var dashboardWidgets: some View { - VStack(alignment: .leading, spacing: widgetsCollapsed ? 0 : OmiSpacing.xl) { - if shouldShowSuggestionBanner { - PromptSuggestionBanner( - suggestions: postOnboardingSuggestions, - onOpen: { - dismissSuggestionBanner() - NotificationCenter.default.post(name: .showTryAskingPopup, object: nil) - }, - onAsk: handleSuggestedPrompt, - onDismiss: dismissSuggestionBanner - ) - } - - dashboardIntelligenceError - - FocusedGoalsSection( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onShowAll: { showingAllGoals = true } - ) - - if widgetsCollapsed { - // Collapsed: slim summary bar - collapsedWidgetBar - } else { - // Expanded: full Tasks + Goals cards - expandedWidgets - - // Collapse button centered below widgets - collapseButton - } - } - .padding(.horizontal, OmiSpacing.section) - .padding(.top, widgetsCollapsed ? OmiSpacing.xl : OmiSpacing.section) - .padding(.bottom, OmiSpacing.sm) - .omiAnimation(.easeInOut(duration: 0.25), value: widgetsCollapsed) - } - - @ViewBuilder - private var dashboardIntelligenceError: some View { - if let error = intelligenceStore.error, !error.isEmpty { - HStack(spacing: OmiSpacing.sm) { - Image(systemName: "exclamationmark.triangle.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(PageGlass.warning) - Text(error) - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - Spacer(minLength: OmiSpacing.sm) - Button("Retry") { - Task { await intelligenceStore.load() } - } - .buttonStyle(.plain) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundColor(Ink.primary) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFillHover) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .accessibilityIdentifier("dashboard-intelligence-error") - } - } - - private var collapsedWidgetBar: some View { - Button(action: { widgetsCollapsed = false }) { - HStack(spacing: OmiSpacing.lg) { - // Tasks summary - HStack(spacing: OmiSpacing.xs) { - Image(systemName: "checklist") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - Text( - incompleteTaskCount == 0 - ? "No tasks" - : "\(incompleteTaskCount) task\(incompleteTaskCount == 1 ? "" : "s")" - ) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - - // Subtle divider dot - Circle() - .fill(Ink.secondary) - .frame(width: 3, height: 3) - - // Goals summary - HStack(spacing: OmiSpacing.xs) { - Image(systemName: "target") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - Text( - activeGoalCount == 0 - ? "No goals" - : "\(activeGoalCount) goal\(activeGoalCount == 1 ? "" : "s")" - ) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - - Spacer() - - // Expand chevron - Image(systemName: "chevron.down") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.secondary) - } - .padding(.horizontal, OmiSpacing.lg) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill(Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .transition(.opacity.combined(with: .move(edge: .top))) - } - - private var expandedWidgets: some View { - // fixedSize(vertical:) constrains the Grid to its row's intrinsic - // height so Tasks/Goals stop competing with ChatMessagesView for - // vertical space; each cell still fills the row, so the two cards - // remain visually equal-height (matching the taller intrinsic). - Grid(horizontalSpacing: OmiSpacing.xl, verticalSpacing: OmiSpacing.xl) { - GridRow { - TasksWidget( - overdueTasks: viewModel.overdueTasks, - todaysTasks: viewModel.todaysTasks, - recentTasks: viewModel.recentTasks, - onToggleCompletion: { task in - Task { - await viewModel.toggleTaskCompletion(task) - } - } - ) - .frame(minWidth: 0, maxWidth: .infinity) - - if intelligenceStore.accountGeneration != nil { - canonicalGoalsWidget - } else { - GoalsWidget( - goals: viewModel.goals, - onCreateGoal: { title, current, target in - Task { - await viewModel.createGoal( - title: title, - goalType: .numeric, - targetValue: target, - unit: nil - ) - } - }, - onUpdateGoal: { goal, title, current, target in - Task { - await viewModel.updateGoal( - goal, - title: title, - currentValue: current, - targetValue: target - ) - } - }, - onUpdateProgress: { goal, value in - Task { await viewModel.updateGoalProgress(goal, currentValue: value) } - }, - onDeleteGoal: { goal in - Task { await viewModel.deleteGoal(goal) } - } - ) - .frame(minWidth: 0, maxWidth: .infinity) - } - } - } - .fixedSize(horizontal: false, vertical: true) - .transition(.opacity.combined(with: .move(edge: .top))) - } - - private var canonicalGoalsWidget: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - HStack { - Text("Goals") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - Spacer() - Button("All goals") { showingAllGoals = true } - .buttonStyle(.plain) - .scaledFont(size: OmiType.micro, weight: .medium) - } - FocusedGoalsSection( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onShowAll: { showingAllGoals = true } - ) - if intelligenceStore.focusedGoals.isEmpty { - Text("Keep a few outcomes in focus.") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - Spacer(minLength: 0) - } - .padding(OmiSpacing.lg) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 150, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFill) - ) - } - - private var collapseButton: some View { - HStack { - Spacer() - Button(action: { widgetsCollapsed = true }) { - Image(systemName: "chevron.up") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.secondary) - .frame(width: 48, height: 20) - } - .buttonStyle(.plain) - Spacer() - } - } - - private var postOnboardingSuggestions: [String] { - PostOnboardingPromptSuggestions.suggestions() - } - - private var shouldShowSuggestionBanner: Bool { - !routesChatToPrimaryShell && !postOnboardingSuggestions.isEmpty - && !PostOnboardingPromptSuggestions.isDismissed - } - - private func dismissSuggestionBanner() { - PostOnboardingPromptSuggestions.consume() - } - - private func handleSuggestedPrompt(_ suggestion: String) { - PostOnboardingPromptSuggestions.consume() - if DayZeroChips.isDraftPrompt(suggestion) { - // A sentence for the user to finish: prefill and focus, never send. - chatProvider.draftText = DayZeroChips.draftText(for: suggestion) - openHomeChat(focusInput: true) - homeAskFieldFocused = true - return - } - FloatingControlBarManager.shared.openAIInputWithQuery(suggestion) - } - -} - -// MARK: - Home Components - -private enum HomeRowStatus { - case connect - case connected - case open -} - -private enum HomeDestinationProminence { - case primary - case quiet -} - -/// The persistent home ask bar: a pill-shaped chat input with attachments -/// (paperclip + drag-drop, same limits as the chat page), a send/stop action, -/// and the Connect toggle living inside the pill. -struct HomeAskBar: View { - @Binding var text: String - let isSending: Bool - let isStopping: Bool - let isConnectActive: Bool - var focus: FocusState<Bool>.Binding - @Binding var attachments: [ChatAttachment] - let onAttachmentsAdded: ([URL]) -> Void - let onAttachmentRemoved: (String) -> Void - let onSend: () -> Void - let onStop: () -> Void - let onConnect: () -> Void - let onActivate: () -> Void - - @State private var isHovering = false - @State private var isDropTargeted = false - - private var hasText: Bool { - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - /// Requires text: ChatProvider.sendMessage drops empty-text sends, so - /// presenting attachment-only as sendable would silently do nothing. - /// Staged files ride along with the typed message instead. - private var canSend: Bool { - hasText - } - - private var isFocused: Bool { focus.wrappedValue } - - var body: some View { - VStack(spacing: OmiSpacing.sm) { - if !attachments.isEmpty { - AttachmentPreviewRow( - attachments: attachments, - onRemove: onAttachmentRemoved - ) - .padding(.top, OmiSpacing.sm) - .padding(.horizontal, OmiSpacing.md) - } - - HStack(alignment: .bottom, spacing: OmiSpacing.sm) { - Button(action: pickFiles) { - Image(systemName: "paperclip") - .scaledFont(size: OmiType.subheading, weight: .medium) - .foregroundStyle(isFocused ? HomePalette.secondary : HomePalette.muted) - .frame(width: 24, height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(attachments.count >= kMaxChatAttachments) - .help("Attach files") - - // Same trigger the composer and floating bar already click, so Home - // enters the one PushToTalkManager turn instead of a second mic path. - PushToTalkMicButton(diameter: 34) - - // Auto-growing input: `axis: .vertical` + `lineLimit(1...6)` grow the pill - // as text wraps (scrolls past six lines). Return submits, Shift+Return - // newlines — via onKeyPress, since a vertical field would otherwise insert - // a newline on Return and never fire onSubmit. - TextField( - "", - text: $text, - prompt: Text("Ask omi anything").foregroundColor(HomePalette.muted), - axis: .vertical - ) - .textFieldStyle(.plain) - .font(.system(size: 15)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1...6) - .focused(focus) - .padding(.vertical, 7) - .onKeyPress(phases: .down) { press in - guard press.key == .return else { return .ignored } - // Shift+Return falls through to the field's newline handling. - if press.modifiers.contains(.shift) { return .ignored } - handleSubmit() - return .handled - } - - HomeAskBarTrailingControls( - controls: HomeAskBarControls.resolve( - isSending: isSending, isStopping: isStopping, hasText: hasText, isFocused: isFocused), - isConnectActive: isConnectActive, - onSend: handleSubmit, - onStop: onStop, - onConnect: onConnect - ) - } - .padding(.leading, OmiSpacing.lg) - .padding(.trailing, OmiSpacing.sm) - .padding(.vertical, 12) - .frame(minHeight: 58) - } - .background( - RoundedRectangle(cornerRadius: 29, style: .continuous) - .fill(HomeAskBarPalette.wellFill(isEngaged: isHovering || isFocused)) - ) - .overlay { - RoundedRectangle(cornerRadius: 29, style: .continuous) - .stroke( - HomeAskBarPalette.wellStroke(isFocused: isFocused, isDropTargeted: isDropTargeted), - lineWidth: isDropTargeted ? 1.5 : 1) - } - // Keep the composer visually separate without casting a large, opaque bezel - // into the transcript. These are intentionally only 10% of the old shadow. - .shadow(color: .black.opacity(isFocused ? 0.045 : 0.034), radius: 2.4, y: 1) - .contentShape(.rect(cornerRadius: 29)) - .onTapGesture { - onActivate() - focus.wrappedValue = true - } - .onHover { isHovering = $0 } - .onDrop(of: [UTType.fileURL], isTargeted: $isDropTargeted, perform: handleDrop) - .omiAnimation(.easeOut(duration: 0.16), value: isFocused) - .omiAnimation(.easeOut(duration: 0.16), value: canSend) - .omiAnimation(.easeOut(duration: 0.16), value: attachments.count) - } - - private func pickFiles() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = true - panel.allowedContentTypes = [ - .image, .jpeg, .png, .gif, .heic, .heif, .webP, .tiff, .bmp, - .pdf, .plainText, .json, .commaSeparatedText, .html, - .text, .content, - ] - if panel.runModal() == .OK { - let remaining = max(0, kMaxChatAttachments - attachments.count) - let urls = Array(panel.urls.prefix(remaining)) - if !urls.isEmpty { - onAttachmentsAdded(urls) - } - } - } - - private func handleDrop(providers: [NSItemProvider]) -> Bool { - ChatAttachmentDropHandler.collectURLs(from: providers) { [attachments] urls in - guard !urls.isEmpty else { return } - let remaining = max(0, kMaxChatAttachments - attachments.count) - let allowed = Array(urls.prefix(remaining)) - if !allowed.isEmpty { - onAttachmentsAdded(allowed) - } - } - } - - private func handleSubmit() { - if isSending { - onStop() - } else if canSend { - onSend() - } - } - -} - -/// One knows-list row: leading kind icon, single-line text, and either a -/// dismiss × (task/insight) or an ask ↗ (question) on the trailing edge. -private struct HomeKnowsRowView: View { - let row: HomeKnowsRow - let onOpen: () -> Void - let onDismiss: ((OmiAPI.TaskIntelligenceFeedbackReason?) -> Void)? - let onLater: (() -> Void)? - - @State private var isHovering = false - @State private var showDismissReasons = false - @State private var choseReason = false - - private var leadingIcon: String { - switch row.kind { - case .task: return "circle" - case .insight: return ProactiveNotificationBadge.insightSystemImage - case .question: return "bubble.left" - } - } - - var body: some View { - Button(action: onOpen) { - HStack(spacing: OmiSpacing.md) { - Image(systemName: leadingIcon) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(isHovering ? HomePalette.secondary : HomePalette.muted) - .frame(width: 18) - - Text(row.text) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.secondary) - .lineLimit(1) - - Spacer(minLength: 8) - - trailingAccessory - } - .padding(.horizontal, OmiSpacing.lg) - .frame(height: 46) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 13)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .contextMenu { - if let onLater { - Button("Later") { onLater() } - } - if onDismiss != nil { - Button("Dismiss") { handleDismissTap() } - } - } - .accessibilityLabel(row.text) - .accessibilityIdentifier("home-knows-\(row.id)") - } - - @ViewBuilder - private var trailingAccessory: some View { - if onDismiss != nil { - Button(action: handleDismissTap) { - Image(systemName: "xmark") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.secondary : HomePalette.faint) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Dismiss") - .accessibilityLabel("Dismiss") - .popover(isPresented: $showDismissReasons) { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Optional reason") - .scaledFont(size: OmiType.caption, weight: .semibold) - ForEach(Self.reasonChoices, id: \.label) { choice in - Button(choice.label) { - choseReason = true - onDismiss?(choice.reason) - showDismissReasons = false - } - .buttonStyle(.bordered) - } - } - .padding(OmiSpacing.md) - .frame(width: 210) - } - .onChange(of: showDismissReasons) { wasShowing, isShowing in - guard wasShowing, !isShowing, !choseReason else { return } - onDismiss?(nil) - } - } else { - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.faint) - } - } - - /// Insight dismissals offer the same optional feedback reasons the old - /// What-matters-now cards recorded; task rows just hide for the session. - private func handleDismissTap() { - if case .insight = row.kind { - choseReason = false - showDismissReasons = true - } else { - onDismiss?(nil) - } - } - - private static let reasonChoices: [(label: String, reason: OmiAPI.TaskIntelligenceFeedbackReason)] = [ - ("Already handled", .already_handled), - ("Not mine", .not_mine), - ("Not useful", .not_useful), - ] -} - -private struct HomePrimaryRouteButton: View { - let title: String - let brand: ConnectorBrand - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: brand, size: 20, cornerRadius: OmiChrome.badgeRadius) - - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(HomeAskBarPalette.primaryLabel) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(minWidth: 118) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(HomeAskBarPalette.primaryFill.opacity(isHovering ? 0.88 : 1)) - ) - .shadow(color: .black.opacity(isHovering ? 0.12 : 0.08), radius: 10, y: 4) - .contentShape(.rect(cornerRadius: OmiChrome.smallControlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("Connect \(title)") - } -} - -private struct HomeInlineAction: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.action = action - } - - init(title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.xs) { - icon - - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .background( - Capsule(style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.3) : Ink.separator, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } - - @ViewBuilder - private var icon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 18, cornerRadius: OmiChrome.badgeRadius) - } else if let systemImage { - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 18, height: 18) - } - } -} - -private struct HomeSourceIconTile: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let usesOmiDeviceImage: Bool - let isConnected: Bool - let isBrowse: Bool - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - brand: ConnectorBrand, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = brand - self.systemImage = nil - self.usesOmiDeviceImage = false - self.isConnected = isConnected - self.isBrowse = false - self.action = action - } - - init( - title: String, - systemImage: String, - isBrowse: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.usesOmiDeviceImage = false - self.isConnected = false - self.isBrowse = isBrowse - self.action = action - } - - init( - title: String, - usesOmiDeviceImage: Bool, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = nil - self.usesOmiDeviceImage = usesOmiDeviceImage - self.isConnected = isConnected - self.isBrowse = false - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(spacing: OmiSpacing.sm) { - ZStack(alignment: .topTrailing) { - icon - - if isConnected { - Circle() - .fill(HomePalette.green) - .frame(width: 9, height: 9) - .overlay(Circle().stroke(HomePalette.tile, lineWidth: 2)) - .offset(x: 2, y: -2) - } - } - - HStack(spacing: OmiSpacing.xxs) { - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.72) - - if isBrowse { - Image(systemName: "chevron.right") - .scaledFont(size: 8, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - } - } - .frame(maxWidth: .infinity) - .frame(height: 92) - .background( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(isHovering ? 0.10 : 0), radius: 12, y: 4) - .contentShape(.rect(cornerRadius: 17)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } - - @ViewBuilder - private var icon: some View { - if usesOmiDeviceImage { - HomeOmiDeviceIcon(size: 42, cornerRadius: OmiChrome.smallControlRadius) - } else if let brand { - ConnectorBrandIcon(brand: brand, size: 42, cornerRadius: OmiChrome.smallControlRadius) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill) - Image(systemName: systemImage) - .scaledFont(size: 19, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 42, height: 42) - } - } -} - -private struct HomeOmiDeviceIcon: View { - let size: CGFloat - let cornerRadius: CGFloat - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Ink.rowFill) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - - if let deviceImage = OmiDeviceImage.shared { - Image(nsImage: deviceImage) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .padding(size * 0.16) - } else { - Image(systemName: "wave.3.right.circle.fill") - .scaledFont(size: size * 0.45, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - } - .frame(width: size, height: size) - } -} - -private struct HomeDataSourceCard: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let actionTitle: String - let isConnected: Bool - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - actionTitle: String, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.actionTitle = actionTitle - self.isConnected = isConnected - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - actionTitle: String, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.actionTitle = actionTitle - self.isConnected = isConnected - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - icon - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 10) - - HStack(spacing: OmiSpacing.xxs) { - if isConnected { - Circle() - .fill(HomePalette.green) - .frame(width: 5, height: 5) - } - - Text(actionTitle) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(isConnected ? HomePalette.green : HomePalette.secondary) - .lineLimit(1) - - if !isConnected && actionTitle == "Browse" { - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - } - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .frame(height: 64) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(isHovering ? 0.08 : 0), radius: 10, y: 3) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle), \(actionTitle)") - } - - @ViewBuilder - private var icon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 36, cornerRadius: OmiChrome.smallControlRadius) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill) - Image(systemName: systemImage) - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 36, height: 36) - } - } -} - -private struct HomeAIChoiceButton: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let usesOmiMark: Bool - let isPrimary: Bool - let isConnected: Bool - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, brand: ConnectorBrand, isPrimary: Bool = false, isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = brand - self.systemImage = nil - self.usesOmiMark = false - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - init( - title: String, systemImage: String, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.usesOmiMark = false - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - init( - title: String, usesOmiMark: Bool, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = nil - self.usesOmiMark = usesOmiMark - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - icon - - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Spacer(minLength: 8) - - if isConnected { - Text("Connected") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.faint) - } - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background(buttonBackground) - .overlay(buttonStroke) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } - - @ViewBuilder - private var icon: some View { - if usesOmiMark { - HomeOmiMarkIcon(size: 24, cornerRadius: 7) - } else if let brand { - ConnectorBrandIcon(brand: brand, size: 24, cornerRadius: 7) - } else if let systemImage { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .bold) - .foregroundStyle(HomePalette.ink) - .frame(width: 24, height: 24) - } - } - - private var buttonBackground: some View { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - } - - private var buttonStroke: some View { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke( - isHovering ? Ink.hairline : Ink.separator, - lineWidth: 1 - ) - } -} - -private struct HomeOmiMarkIcon: View { - let size: CGFloat - let cornerRadius: CGFloat - - private static let markImage: NSImage? = { - guard let url = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png") else { - return nil - } - return NSImage(contentsOf: url) - }() - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Ink.rowFill) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - - if let image = Self.markImage { - Image(nsImage: image) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .padding(size * 0.18) - } else { - OmiDotRing() - .frame(width: size * 0.58, height: size * 0.58) - } - } - .frame(width: size, height: size) - } -} - -private struct OmiDotRing: View { - var body: some View { - ZStack { - ForEach(0..<8, id: \.self) { index in - Circle() - .fill(HomePalette.ink) - .frame(width: 3.5, height: 3.5) - .offset(y: -6) - .rotationEffect(.degrees(Double(index) * 45)) - } - } - } -} - -private struct HomeOrbitButton: View { - let title: String - let brand: ConnectorBrand - let badge: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, badge: String? = nil, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.badge = badge - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(spacing: OmiSpacing.xs) { - ZStack(alignment: .topTrailing) { - ConnectorBrandIcon(brand: brand, size: 44, cornerRadius: 13) - .shadow(color: .black.opacity(isHovering ? 0.16 : 0.08), radius: 9, y: 4) - - if let badge { - Text(badge) - .scaledFont(size: 8, weight: .bold) - .foregroundStyle(HomeAskBarPalette.primaryLabel) - .padding(.horizontal, OmiSpacing.xxs) - .padding(.vertical, OmiSpacing.hairline) - .background(Capsule(style: .continuous).fill(HomePalette.green)) - .offset(x: 8, y: -6) - } - } - - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - } - .padding(OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .fill(isHovering ? HomePalette.panel : Color.clear) - ) - .contentShape(.rect(cornerRadius: OmiChrome.controlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } -} - -private struct HomeDestinationCapsule: View { - let title: String - let subtitle: String - let brand: ConnectorBrand - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: brand, size: 34, cornerRadius: 9) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.green : HomePalette.faint) - } - .padding(OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.32) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } -} - -private struct HomeCommandCard: View { - let onChatGPT: () -> Void - let onClaude: () -> Void - let onAskOmi: () -> Void - - var body: some View { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: OmiSpacing.md) { - Text("Connect Omi to ChatGPT, Claude, or ask Omi directly...") - .scaledFont(size: OmiType.subheading, weight: .regular) - .foregroundStyle(HomePalette.faint) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, OmiSpacing.hairline) - - Button(action: onAskOmi) { - Image(systemName: "arrow.up.circle") - .scaledFont(size: 24, weight: .regular) - .foregroundStyle(HomePalette.faint) - } - .buttonStyle(.plain) - .help("Ask Omi") - } - .padding(.horizontal, OmiSpacing.lg) - .padding(.top, OmiSpacing.lg) - .padding(.bottom, OmiSpacing.xxl) - - HStack(spacing: OmiSpacing.sm) { - Button(action: onChatGPT) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: .chatgpt, size: 22, cornerRadius: OmiChrome.badgeRadius) - Text("Connect ChatGPT") - .scaledFont(size: OmiType.body, weight: .semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.sm) - .foregroundStyle(HomeAskBarPalette.primaryLabel) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(HomeAskBarPalette.primaryFill) - ) - } - .buttonStyle(.plain) - - Button(action: onClaude) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: .claude, size: 22, cornerRadius: OmiChrome.badgeRadius) - Text("Claude") - .scaledFont(size: OmiType.body, weight: .semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.sm) - .foregroundStyle(HomePalette.secondary) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(HomePalette.tile) - ) - } - .buttonStyle(.plain) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.bottom, OmiSpacing.md) - } - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(HomePalette.panel) - .shadow(color: .black.opacity(0.10), radius: 16, y: 8) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .frame(maxWidth: 720) - } -} - -private struct HomeSourceTile: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let status: HomeRowStatus - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.status = status - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.status = status - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - HStack(alignment: .top) { - iconView - Spacer() - statusView - } - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - } - .padding(OmiSpacing.sm) - .frame(minHeight: 78, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.4) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 9)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var iconView: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 28, cornerRadius: 7) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(HomePalette.panel) - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 28, height: 28) - } - } - - @ViewBuilder - private var statusView: some View { - switch status { - case .connect: - Image(systemName: "plus") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(HomePalette.secondary) - case .connected: - Image(systemName: "checkmark") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(HomePalette.green) - case .open: - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(HomePalette.secondary) - } - } -} - -private struct HomeMemoryMetricCard: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill) - - Image(systemName: systemImage) - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(HomePalette.ink) - } - .frame(width: 42, height: 42) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(value) - .font(.system(size: 21, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.72) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? Ink.primary : Ink.secondary) - } - .padding(.horizontal, OmiSpacing.md) - .frame(height: 76) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 17)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeMetricPill: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.xs) { - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - - Text(value) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(maxWidth: .infinity) - .background( - Capsule(style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.panel) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.34) : Ink.separator, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeGlassPanel<Content: View>: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .padding(OmiSpacing.lg) - .frame(maxWidth: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(HomePalette.panel) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 14, y: 6) - } -} - -private struct HomeStageHeader: View { - let eyebrow: String - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text(eyebrow.uppercased()) - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.green) - - Text(title) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } -} - -private struct HomeBridgeChevron: View { - var body: some View { - VStack(spacing: OmiSpacing.sm) { - Rectangle() - .fill( - LinearGradient( - colors: [.clear, Ink.separator, .clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: 1, height: 150) - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.subheading, weight: .bold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 22) - .accessibilityHidden(true) - } -} - -private struct HomeSourceRow: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let status: HomeRowStatus - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.status = status - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.status = status - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - rowIcon - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - statusView - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(isHovering ? Ink.listeningGreen.opacity(0.28) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 13)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var rowIcon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 32, cornerRadius: OmiChrome.elementRadius) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.elementRadius, style: .continuous) - .fill(Ink.rowFillHover) - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 32, height: 32) - } - } - - @ViewBuilder - private var statusView: some View { - switch status { - case .connect: - Image(systemName: "plus") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(Ink.listeningGreen) - case .connected: - Image(systemName: "checkmark") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(Ink.listeningGreen) - case .open: - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(Ink.listeningGreen) - } - } -} - -private struct HomeDestinationRow: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let prominence: HomeDestinationProminence - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - prominence: HomeDestinationProminence = .primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.prominence = prominence - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - prominence: HomeDestinationProminence = .primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.prominence = prominence - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - rowIcon - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(prominence == .primary ? HomePalette.ink : HomePalette.secondary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.green : HomePalette.faint) - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.sm) - .background(rowBackground) - .overlay(rowStroke) - .contentShape(.rect(cornerRadius: OmiChrome.chipRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var rowIcon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 34, cornerRadius: 9) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(HomePalette.tile) - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 34, height: 34) - } - } - - private var rowBackground: some View { - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill( - prominence == .primary - ? HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - : (isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - } - - private var rowStroke: some View { - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .stroke( - prominence == .primary - ? HomePalette.green.opacity(isHovering ? 0.42 : 0.24) - : isHovering ? Ink.hairline : Ink.separator, - lineWidth: 1 - ) - } -} - -private struct HomeMetricTile: View { - let title: String - let value: String - let systemImage: String - let accent: Color - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: OmiSpacing.xs) { - HStack { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(accent) - - Spacer() - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? accent : Ink.secondary) - } - - Text(value) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - .padding(OmiSpacing.md) - .frame(minHeight: 86, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .stroke(isHovering ? accent.opacity(0.34) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: OmiChrome.controlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeSectionHeader: View { - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text(title) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(Ink.primary) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - } - } -} - -struct HomeStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let isToggling: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(height: 34) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .onHover { isHovering = $0 } - .help("\(title): \(status.text)") - .accessibilityLabel("\(title) \(status.text)") - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? Ink.rowFill : Color.clear - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return isHovering ? Ink.hairline : Ink.separator - } -} - -struct HomeListeningStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let modeTitle: String - /// Only Meetings wait: the session is armed, the mic is paused, and a click turns - /// listening off. Help/VoiceOver must not reuse the "Off" sentence for that. - let isAwaitingMeeting: Bool - let isToggling: Bool - let action: () -> Void - - /// Hover / VoiceOver copy. An armed Only Meetings wait is inactive (mic paused) - /// but not off — a click turns listening off, it does not start it. - static func helpText( - status: HomeStatusState, modeTitle: String, isAwaitingMeeting: Bool - ) -> String { - if status == .inactive && isAwaitingMeeting { - return - "Listening: waiting for a call (\(modeTitle)). Nothing is being transcribed. Click to turn off." - } - return "Listening: \(status.text), \(modeTitle)" - } - - // Hover reveals the selected mode, but Settings owns the only picker. - @State private var isHovering = false - - var body: some View { - HStack(spacing: 0) { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .lineLimit(1) - - // Mode ("Always" / "In meeting" / …) is revealed only on - // hover to keep the resting pill clean. - if isHovering { - Text(modeTitle) - .scaledFont(size: 8, weight: .medium) - .foregroundStyle(status.isActive ? HomePalette.secondary : HomePalette.muted) - .lineLimit(1) - .transition(.opacity) - } - } - } - .padding(.leading, OmiSpacing.md) - .padding(.trailing, OmiSpacing.sm) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .help(Self.helpText(status: status, modeTitle: modeTitle, isAwaitingMeeting: isAwaitingMeeting)) - .accessibilityLabel( - Self.helpText(status: status, modeTitle: modeTitle, isAwaitingMeeting: isAwaitingMeeting)) - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - .frame(height: 34) - .onHover { isHovering = $0 } - .omiAnimation(.easeInOut(duration: 0.14), value: isHovering) - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? Ink.rowFill : Color.clear - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return isHovering ? Ink.hairline : Ink.separator - } -} - -private struct HomeIconActionButton: View { - let title: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.muted) - .frame(width: 34, height: 34) - .background( - Circle() - .fill(isHovering ? HomePalette.tileHover : HomePalette.panel) - ) - .overlay( - Circle() - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} - -private struct HomeConnectorCard: View { - let title: String - let subtitle: String - let brand: ConnectorBrand - let actionTitle: String - let status: String? - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ConnectorBrandIcon(brand: brand, size: 36, cornerRadius: 9) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - - Spacer(minLength: 10) - - if let status { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "checkmark") - .scaledFont(size: OmiType.micro, weight: .bold) - Text(status) - .scaledFont(size: OmiType.caption, weight: .semibold) - } - .foregroundStyle(Ink.listeningGreen) - .lineLimit(1) - } else { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "plus") - .scaledFont(size: OmiType.micro, weight: .bold) - Text(actionTitle) - .scaledFont(size: OmiType.caption, weight: .semibold) - } - .foregroundStyle(Ink.listeningGreen) - .lineLimit(1) - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(minHeight: 56) - .background(cardBackground) - .overlay(cardStroke) - .contentShape(.rect(cornerRadius: OmiChrome.smallControlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(status ?? actionTitle)") - } - - private var cardBackground: some View { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - } - - private var cardStroke: some View { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke( - isHovering ? Ink.listeningGreen.opacity(0.32) : Ink.separator, - lineWidth: 1 - ) - } -} - -private struct HomeMoreAppsCard: View { - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFillHover) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - - Image(systemName: "square.grid.2x2.fill") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 36, height: 36) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text("Connect more") - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.primary) - - Text("Browse all apps") - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(Ink.listeningGreen) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(minHeight: 56) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke( - isHovering ? Ink.listeningGreen.opacity(0.32) : Ink.separator, - lineWidth: 1 - ) - ) - .contentShape(.rect(cornerRadius: OmiChrome.smallControlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } -} - -private struct HomeFlowArrow: View { - var body: some View { - VStack(spacing: OmiSpacing.xxs) { - Rectangle() - .fill(Ink.separator) - .frame(width: 1, height: 14) - - Image(systemName: "chevron.down") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } -} - -private struct HomeMetricCard: View { - let title: String - let value: String - let subtitle: String - let systemImage: String - let accent: Color - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(accent.opacity(0.16)) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke(accent.opacity(0.28), lineWidth: 1) - ) - - Image(systemName: systemImage) - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(accent) - } - .frame(width: 38, height: 38) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(value) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(title) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(isHovering ? accent : Ink.secondary) - } - .padding(OmiSpacing.md) - .frame(minHeight: 64) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .stroke(isHovering ? accent.opacity(0.34) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: OmiChrome.chipRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value), \(subtitle)") - } -} - -private struct HomeAIButton: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.action = action - } - - init(title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - if let brand { - ConnectorBrandIcon(brand: brand, size: 26, cornerRadius: 7) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Ink.rowFillHover) - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 26, height: 26) - } - - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? Ink.listeningGreen : Ink.secondary) - } - .padding(.leading, OmiSpacing.sm) - .padding(.trailing, OmiSpacing.md) - .padding(.vertical, OmiSpacing.xs) - .background( - Capsule(style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? Ink.listeningGreen.opacity(0.32) : Ink.separator, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } -} - -#if canImport(PreviewsMacros) - #Preview { - DashboardPage( - viewModel: DashboardViewModel(), - appState: AppState(), - appProvider: AppProvider(), - chatProvider: ChatProvider(), - memoriesViewModel: MemoriesViewModel(), - selectedIndex: .constant(0) - ) - .frame(width: 800, height: 600) - .inkGlassPanel() - } -#endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift index 8e1a6be9f4b..576782e3c67 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift @@ -865,60 +865,6 @@ extension SettingsContentView { } } - settingsCard(settingId: "advanced.preferences.legacyhome") { - HStack(spacing: OmiSpacing.lg) { - Image(systemName: "rectangle.split.2x1") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Use old Home design") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Text("Show the previous chat-first dashboard instead of the simplified Home") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - - Spacer() - - // Same card shape, same trailing slot, same kind of preference as the two rows it sits - // between — an AppKit checkbox here is a second switch vocabulary in one stack. - Toggle("", isOn: $useLegacyHomeDesign) - .toggleStyle(OmiToggleStyle()) - .labelsHidden() - } - } - - if useLegacyHomeDesign { - settingsCard(settingId: "advanced.preferences.oldesthome") { - HStack(spacing: OmiSpacing.lg) { - Image(systemName: "rectangle.stack") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Use oldest Home theme") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Text("Show the original widgets-and-chat Home") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - - Spacer() - - Toggle("", isOn: $useOldestHomeDesign) - .toggleStyle(OmiToggleStyle()) - .labelsHidden() - } - } - } - settingsCard(settingId: "advanced.preferences.speaknotifications") { HStack(spacing: OmiSpacing.lg) { Image(systemName: "speaker.wave.2") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift index dd26fdb3d70..2d64d05738a 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift @@ -361,8 +361,6 @@ struct SettingsContentView: View { // Multi-chat mode setting @AppStorage("multiChatEnabled") var multiChatEnabled = false @AppStorage("conversationsCompactView") var conversationsCompactView = true - @AppStorage("useLegacyHomeDesign") var useLegacyHomeDesign = false - @AppStorage("useOldestHomeDesign") var useOldestHomeDesign = false @AppStorage("speakNotificationsAloud") var speakNotificationsAloud = false @AppStorage(DefaultsKey.integrationNudgesEnabled.rawValue) var integrationNudgesEnabled = true diff --git a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift index 62494bf2e34..1908c582bce 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift @@ -27,16 +27,16 @@ struct QueryAnswerThread: View { /// Re-sends the question that failed, through the host's one send — never a second send path. The /// host holds that question, because the composer is emptied by the send that failed. let onRetry: () -> Void - /// Enables the sampled Chat-first inline entity controls without giving this thread a second - /// provider, transcript, or lifecycle owner. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + /// The inline entity controls' owners. It gives this thread no second provider, transcript, or + /// lifecycle owner. + let chatFirstRichBlockContext: ChatFirstRichBlockContext @State private var didReportChatFirstTranscriptPage = false var body: some View { VStack(alignment: .leading, spacing: OmiSpacing.sm) { ChatMessagesView( - messages: citationSafeMessages, + messages: chatProvider.messages, conversationIdentity: chatProvider.currentSessionId ?? ChatConversationIdentity.mainChatDefault, isSending: chatProvider.isSending, @@ -55,6 +55,18 @@ struct QueryAnswerThread: View { onRetry: { Task { await chatProvider.retryLoad() } }, localSendToken: chatProvider.localSendToken, onCancelTurn: { chatProvider.stopAgent(owner: .mainChat) }, + // A spawned-agent card in the main transcript opens the agent through the + // one resolver the notch uses; this used to be wired only on the deleted + // Dashboard chat, so Home's agent cards had no way in. + onOpenAgent: { agentID, completion in + FloatingControlBarManager.shared.openAgentChatFromTimeline( + agentID: agentID, completion: completion) + }, + onOpenAgentRef: { ref, completion in + FloatingControlBarManager.shared.openAgentChatFromTimeline( + ref: ref, completion: completion) + }, + // **Not zero.** The assistant's identity mark is drawn in an overlay offset // `ChatOmiMarkPlacement.markGutter` to the left of the message column, so a transcript with // no leading inset draws it outside the panel and clips it away — leaving omi's replies as @@ -65,12 +77,12 @@ struct QueryAnswerThread: View { // `ChatMessagesView` keeps its rows eagerly mounted on purpose — a lazy // stack re-estimates off-screen rich-Markdown heights and hands AppKit // the wrong anchor mid-gesture — so how many rows are mounted is the - // whole cost. It picks the compact window automatically for a caller - // that passes a chat-first block context; the ordinary QueryShellHome - // path has none, so it used to mount the 500-row default into a panel - // 460 pt tall: 910 ms and 607 native views for 400 messages, against - // 114 ms and 84 for the same transcript compact. `Show older messages` - // is already the way back to the rest of it. + // whole cost. The 500-row default in a panel 460 pt tall cost 910 ms and + // 607 native views for 400 messages, against 114 ms and 84 for the same + // transcript compact. `Show older messages` is already the way back to + // the rest of it. Passed explicitly: every host now carries a block + // context, so deriving the window from "has a context" would have + // silently shrunk the task panel's too. chatFirstRichBlockContext: chatFirstRichBlockContext, transcriptWindowPolicy: .compactHome, verticalContentPadding: OmiSpacing.sm, @@ -127,45 +139,17 @@ struct QueryAnswerThread: View { } .onDisappear { didReportChatFirstTranscriptPage = false - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptDidDisappear() + chatFirstRichBlockContext.promptMaterializationCoordinator.chatTranscriptDidDisappear() } } /// Prompt materialization is visible-chat gated: the coordinator may run only after the one /// mounted transcript has its first page, and leaving answer mode immediately makes it inert. private func reportChatFirstTranscriptPageIfReady() { - guard !didReportChatFirstTranscriptPage, - chatFirstRichBlockContext != nil, - chatProvider.isMainChatJournalFirstPageReady + guard !didReportChatFirstTranscriptPage, chatProvider.isMainChatJournalFirstPageReady else { return } didReportChatFirstTranscriptPage = true - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() - } - - /// The legacy shell has no exact goal destination. Preserve the historical source preview but - /// make its marker unavailable before it reaches the renderer, instead of presenting a button - /// whose action cannot honor the cited identity. Chat-first keeps its typed goal route. - private var citationSafeMessages: [ChatMessage] { - guard chatFirstRichBlockContext == nil else { return chatProvider.messages } - return chatProvider.messages.map { message in - var message = message - message.contentBlocks = message.contentBlocks.map { block in - guard case .citation(let id, let reference) = block, reference.kind == .goal else { - return block - } - return .citation( - id: id, - reference: ChatCitationReference( - ordinal: reference.ordinal, - kind: .unavailable, - sourceID: "", - title: reference.displayTitle, - preview: reference.preview, - createdAt: reference.createdAt, - appName: reference.appName)) - } - return message - } + chatFirstRichBlockContext.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift index 0fa34864221..93422bf7a10 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift @@ -17,9 +17,6 @@ // It paints **no background**. The window's ground is AppKit's (`ShellGlassGround`); the two panels // here wear the app's glass through `inkGlassPanel` and nothing else does. // -// The legacy hub is still here, behind the `useLegacyHomeDesign` setting that already gated it, so -// the change is reversible by the person it happened to rather than by a rebuild. -// // **This is the app's only chat destination**, which makes it the only place the controls of the // deleted standalone chat page can live (`6be26e85bc`; INV-NAV-1 forbids bringing that page back). // So this file also hosts: the chat overflow menu (copy / clear / AI settings), the way back into @@ -44,16 +41,10 @@ struct QueryShellHome: View { @ObservedObject var memoriesViewModel: MemoriesViewModel @ObservedObject private var tasksStore = TasksStore.shared var taskChatCoordinator: TaskChatCoordinator? = nil - /// The Chat-first shell keeps the existing modern Home presentation even when the reversible legacy - /// preference is enabled. This is presentation-only; capability sampling and rich-block access - /// remain owned by `ChatFirstShell`. - var forceModernPresentation: Bool = false - /// Non-nil only for the sampled Chat-first main-chat surface. It enables the existing inline entity - /// controls without creating another provider or transcript. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil - @Binding var selectedIndex: Int - - @AppStorage("useLegacyHomeDesign") private var useLegacyHomeDesign = false + /// Typed navigation and the interactable content-block controls. Every Chat + /// surface has one; it creates no second provider or transcript. + let chatFirstRichBlockContext: ChatFirstRichBlockContext + @AppStorage(MemoryHubDestination.storageKey) private var memoryDestinationRawValue = MemoryHubDestination.memories.rawValue @@ -97,26 +88,8 @@ struct QueryShellHome: View { /// since given away — which is precisely the case the `didBecomeActive` claim below exists for. @State private var caretClaims = 0 - private var usesLegacyPresentation: Bool { - !HomeDesignPresentation.queryShellOwnsItsPanels( - useLegacyHomeDesign: useLegacyHomeDesign, - forceModernPresentation: forceModernPresentation) - } - var body: some View { - if usesLegacyPresentation { - DashboardPage( - viewModel: viewModel, - homeStatusStore: homeStatusStore, - appState: appState, - appProvider: appProvider, - chatProvider: chatProvider, - memoriesViewModel: memoriesViewModel, - taskChatCoordinator: taskChatCoordinator, - selectedIndex: $selectedIndex) - } else { - querySurface - } + querySurface } private var querySurface: some View { @@ -176,9 +149,8 @@ struct QueryShellHome: View { claimCaret() } // A prefilled draft (first-real-app card, daily-summary follow-up) lands in the composer, - // focused and unsent. The legacy presentation's Dashboard consumes the same request itself. + // focused and unsent. This is the app's only chat destination, so it is the only consumer. .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in - guard !usesLegacyPresentation else { return } takePendingDraftIfAny() } // **Coming back to Omi puts the caret back in the field.** This surface's whole job is to be typed @@ -227,7 +199,6 @@ struct QueryShellHome: View { // search text, so an action that promises the conversation must clear it or its effect lands // hidden behind the results panel while the bridge reports success. .onReceive(NotificationCenter.default.publisher(for: .homeStageOpenChat)) { _ in - guard !usesLegacyPresentation else { return } searchText = HomeBridgeIntent.openChat.searchTextAfter(searchText) claimCaret() } @@ -235,18 +206,17 @@ struct QueryShellHome: View { // The bridge action posts this and reports success, so an unobserved notification here would // be the "bridge answered ok and nothing happened" defect this file's actions exist to avoid. .onReceive(NotificationCenter.default.publisher(for: .homeStageClose)) { _ in - guard !usesLegacyPresentation else { return } searchText = HomeBridgeIntent.closePanel.searchTextAfter(searchText) claimCaret() } .onReceive(NotificationCenter.default.publisher(for: .homeStageAsk)) { note in - guard !usesLegacyPresentation, let query = note.userInfo?["query"] as? String else { return } + guard let query = note.userInfo?["query"] as? String else { return } searchText = HomeBridgeIntent.ask.searchTextAfter(searchText) chatProvider.draftText = query ask() } .onReceive(NotificationCenter.default.publisher(for: .homeStageAttach)) { note in - guard !usesLegacyPresentation, let path = note.userInfo?["path"] as? String else { return } + guard let path = note.userInfo?["path"] as? String else { return } searchText = HomeBridgeIntent.attach.searchTextAfter(searchText) stageAttachments([URL(fileURLWithPath: path)]) } @@ -471,7 +441,7 @@ struct QueryShellHome: View { } private func takePendingDraftIfAny() { - guard !usesLegacyPresentation, let draft = MainChatNavigationRequestStore.shared.consumeDraft() else { return } + guard let draft = MainChatNavigationRequestStore.shared.consumeDraft() else { return } // Leave search-results mode first, or the prefilled composer stays hidden behind the results. searchText = HomeBridgeIntent.openChat.searchTextAfter(searchText) chatProvider.draftText = draft @@ -482,28 +452,15 @@ struct QueryShellHome: View { /// Opens the exact conversation a spine row is about. /// - /// The row carries the whole record, so the typed deep link can hand it straight to the - /// Conversations host. The id-only path below stays for the shell that has no typed navigation - /// owner, where this page mounts the Conversations host itself. + /// The row carries the whole record, so the typed deep link hands it straight to the + /// Conversations host rather than re-resolving it by id. private func openConversationRecord(_ conversation: ServerConversation) { - if let context = chatFirstRichBlockContext { - context.navigation.open(conversation: conversation) - return - } - openConversation(conversation.id) + chatFirstRichBlockContext.navigation.open(conversation: conversation) } - /// Opens the exact memory a spine row is about, on the same terms the Brain Map's citations use: - /// leave this surface only once the memory actually resolved. + /// Opens the exact memory a spine row is about, on the same terms the Brain Map's citations use. private func openMemory(_ memory: SpineMemory) { - if let context = chatFirstRichBlockContext { - context.navigation.open(focus: .memory(id: memory.id)) - return - } - Task { - await MemoryAtlasCitationOpen.open( - id: memory.id, in: memoriesViewModel, leave: { navigate(.memories) }) - } + chatFirstRichBlockContext.navigation.open(focus: .memory(id: memory.id)) } /// Opens the real Conversations page on the real conversation — never a copy of it here (INV-NAV-1). @@ -521,68 +478,24 @@ struct QueryShellHome: View { navigate(.conversation) } - private func openMemories() { - navigate(.memories) - } - /// Typed citation routing stays at the shell boundary. The inline renderer knows presentation; /// this root owns navigation and preserves exact entity identity where the destination supports it. private func openCitation(_ reference: ChatCitationReference) { guard reference.canOpen else { return } - if let context = chatFirstRichBlockContext { - switch reference.kind { - case .conversation: - let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } - context.navigation.open(focus: .capture(id: reference.sourceID, momentTs: moment)) - case .memory: - context.navigation.open(focus: .memory(id: reference.sourceID)) - case .task: - context.navigation.open(focus: .task(id: reference.sourceID)) - case .goal: - context.navigation.open(focus: .goal(id: reference.sourceID)) - case .screenshot: - guard let id = RewindCitationFocusState.parseScreenshotID(reference.sourceID) else { return } - RewindCitationFocusState.shared.request(id) - context.navigation.selectMore(.rewind) - case .web: - if let url = reference.url { NSWorkspace.shared.open(url) } - case .unavailable: - break - } - return - } - + let navigation = chatFirstRichBlockContext.navigation switch reference.kind { case .conversation: - openConversation(reference.sourceID) + openConversationCitation(reference) case .memory: - Task { @MainActor in - guard await memoriesViewModel.openMemory(id: reference.sourceID) else { return } - openMemories() - } + navigation.open(focus: .memory(id: reference.sourceID)) case .task: - // TasksPage has a typed, owner-bound handoff. Resolve the exact task before changing pages; - // selecting the Tasks tab alone would silently discard the citation's identity. - Task { @MainActor in - guard let authorization = RuntimeOwnerIdentity.captureAuthorizationSnapshot(), - let task = try? await APIClient.shared.getActionItem( - id: reference.sourceID, - expectedOwnerId: authorization.ownerID, - authorizationSnapshot: authorization), - RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) - else { return } - guard !task.isRetired else { return } - TaskNavigationRequestStore.shared.request(task: task) - selectedIndex = SidebarNavItem.tasks.rawValue - } + navigation.open(focus: .task(id: reference.sourceID)) case .goal: - // QueryAnswerThread marks this kind unavailable in the legacy shell before rendering. Keep - // the routing boundary fail-closed as defense in depth. - return + navigation.open(focus: .goal(id: reference.sourceID)) case .screenshot: guard let id = RewindCitationFocusState.parseScreenshotID(reference.sourceID) else { return } RewindCitationFocusState.shared.request(id) - openRewind() + navigation.selectMore(.rewind) case .web: if let url = reference.url { NSWorkspace.shared.open(url) } case .unavailable: @@ -590,6 +503,35 @@ struct QueryShellHome: View { } } + /// A conversation citation must open the conversation it names. The agent + /// cites desktop and phone recordings as readily as Omi-device captures, but + /// the capture focus resolves through the archive's source-scoped fetch — + /// navigating first used to strand a non-capture citation on the + /// Conversations list with nothing opened. Fetch the unscoped record, then + /// let its own provenance pick the route. + private func openConversationCitation(_ reference: ChatCitationReference) { + let navigation = chatFirstRichBlockContext.navigation + let resolutionGeneration = navigation.beginConversationLinkResolution() + Task { @MainActor in + let fetched = try? await APIClient.shared.getConversation(id: reference.sourceID) + guard + let route = ChatFirstConversationLinkPolicy.citationRoute( + forFetched: fetched, + requestedID: reference.sourceID, + momentTimestampMs: reference.momentTimestampMs) + else { return } + switch route { + case .captureFocus(let momentTs): + navigation.open(focus: .capture(id: reference.sourceID, momentTs: momentTs)) + case .exactRecord: + guard let conversation = fetched else { return } + navigation.completeConversationLinkResolution( + conversation: conversation, + generation: resolutionGeneration) + } + } + } + /// Where both of Home's ways into the graph land — the spine's end-of-day card and the header's /// `Brain Map ›`. One route, so the two controls cannot drift apart, and it goes to the surface /// that owns the map. The map is never a destination of this shell (INV-NAV-1). @@ -605,10 +547,17 @@ struct QueryShellHome: View { /// `QueryShellRoute` rather than restating a rail index and a hub raw value at its own call site — /// which is how one of them ends up pointing somewhere the others do not. private func navigate(_ route: QueryShellRoute) { - if let hubView = route.memoryDestination { + let navigation = chatFirstRichBlockContext.navigation + OmiMotion.withGated(.easeOut(duration: 0.08)) { + guard let hubView = route.memoryDestination else { + navigation.selectLegacyDestination(route.navItem) + return + } + // Both halves of the hub state move together — the persisted view and the + // typed route that decides which host is mounted (see `ChatFirstShell`). memoryDestinationRawValue = hubView.rawValue + navigation.selectPrimary(MemoryHubSelectionPolicy.chatFirstRoute(for: hubView)) } - OmiMotion.withGated(.easeOut(duration: 0.08)) { selectedIndex = route.navItem.rawValue } } // MARK: - The corpus diff --git a/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift b/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift deleted file mode 100644 index 7ab2abc9d7d..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift +++ /dev/null @@ -1,1497 +0,0 @@ -@preconcurrency import AppKit -import OmiTheme -import SwiftUI - -// MARK: - Sidebar View -struct SidebarView: View { - @Binding var selectedIndex: Int - @Binding var isCollapsed: Bool - @Binding var memoryDestinationRawValue: Int - @ObservedObject var appState: AppState - @ObservedObject private var authState = AuthState.shared - @ObservedObject private var updaterViewModel = UpdaterViewModel.shared - - // Tier gating (0 = show all, 1-6 = sequential tiers) - @AppStorage("currentTierLevel") private var currentTierLevel = 0 - - // Toggle states for quick controls - @AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true - @AppStorage(AssistantSettings.audioRecordingModeDefaultsKey) private var audioRecordingModeRaw = - AssistantSettings.AudioRecordingMode.onlyMeetings.rawValue - @State private var isMonitoring = false - @State private var isTogglingMonitoring = false - @State private var isTogglingTranscription = false - @State private var monitoringAutoRestartAttempts = 0 - private let maxAutoRestartAttempts = 3 - - // Page loading states (show spinner in place of icon) - @State private var isRewindPageLoading = false - @State private var isConversationsPageLoading = false - @State private var isTasksPageLoading = false - @State private var isAppsPageLoading = false - - // Drag state - @State private var dragOffset: CGFloat = 0 - @GestureState private var isDragging = false - @State private var isProfileButtonHovered = false - - // Constants - private let expandedWidth: CGFloat = 260 - private let collapsedWidth: CGFloat = 64 - private let iconWidth: CGFloat = 20 // Fixed width for all icons - - private var currentWidth: CGFloat { - isCollapsed ? collapsedWidth : expandedWidth - } - - /// Whether a sidebar item is locked at the current tier level - private func isItemLocked(_ item: SidebarNavItem) -> Bool { - currentTierLevel != 0 && currentTierLevel < item.requiredTier - } - - /// Static version: items unlocked at a given tier (used by unlock celebration logic) - static func visibleItems(for tier: Int) -> [SidebarNavItem] { - if tier == 0 { - return SidebarNavItem.mainItems - } - return SidebarNavItem.mainItems.filter { $0.requiredTier <= tier } - } - - var body: some View { - ZStack(alignment: .trailing) { - VStack(alignment: .leading, spacing: 0) { - // Header: Logo + Collapse button on same row - headerSection - .padding(.top, OmiSpacing.md) - .padding(.horizontal, isCollapsed ? OmiSpacing.sm : OmiSpacing.lg) - - Spacer().frame(height: isCollapsed ? 8 : 16) - - // Main navigation section - VStack(alignment: .leading, spacing: 0) { - // Main navigation items - ForEach(SidebarNavItem.mainItems, id: \.rawValue) { item in - Group { - if item == .conversations { - // Conversations - icon shows audio activity when recording - // Audio levels wrapped in a separate view to avoid re-rendering the entire sidebar - AudioLevelNavItem( - icon: item.icon, - label: item.title, - isSelected: selectedIndex == item.rawValue, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isOn: appState.isTranscribing, - isToggling: isTogglingTranscription, - isPageLoading: isConversationsPageLoading, - onTap: { - // Show loading immediately when navigating to Conversations - if selectedIndex != item.rawValue { - isConversationsPageLoading = true - // Fallback timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - if isConversationsPageLoading { - isConversationsPageLoading = false - } - } - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - }, - onToggle: { - toggleTranscription(enabled: !appState.isTranscribing) - } - ) - } else if item == .rewind { - // Rewind - shows pulsing recording icon when both audio and screen are active - NavItemWithStatusView( - icon: item.icon, - label: item.title, - isSelected: selectedIndex == item.rawValue, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isOn: isMonitoring || appState.isTranscribing, - isToggling: isTogglingMonitoring, - isPageLoading: isRewindPageLoading, - onTap: { - // Show loading immediately when navigating to Rewind - if selectedIndex != item.rawValue { - log("SIDEBAR: Rewind tapped, showing loading indicator") - isRewindPageLoading = true - // Fallback timeout in case page load notification never comes - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - if isRewindPageLoading { - log("SIDEBAR: Rewind loading timeout, clearing indicator") - isRewindPageLoading = false - } - } - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - }, - onToggle: { - // Toggle both — on if either is off, off if both are on - let isAnyOn = isMonitoring || appState.isTranscribing - toggleMonitoring(enabled: !isAnyOn) - }, - showRewindIcon: true - ) - } else { - let locked = isItemLocked(item) - NavItemView( - icon: item.icon, - label: item.title, - isSelected: !locked && selectedIndex == item.rawValue, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isLoading: pageLoadingState(for: item), - isLocked: locked, - lockTooltip: locked ? "Unlocks at Tier \(item.requiredTier)" : nil, - onUnlock: { - TierManager.shared.userDidSetTier(item.requiredTier) - setPageLoading(for: item, loading: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - setPageLoading(for: item, loading: false) - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - }, - onTap: { - // Show loading immediately when navigating - if selectedIndex != item.rawValue { - setPageLoading(for: item, loading: true) - // Fallback timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - setPageLoading(for: item, loading: false) - } - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - } - ) - } - } - } - - Spacer() - - // Subscription upgrade banner - // upgradeToPro - - // Update available widget (also surfaced in DesktopTopBar for chat-first) - if updaterViewModel.updateAvailable || updaterViewModel.updateSessionInProgress - || updaterViewModel.updateRestartImminent - || updaterViewModel.updateDeferredForActiveRecording - { - Spacer().frame(height: OmiSpacing.md) - DesktopUpdateStatusBanner(isCollapsed: isCollapsed, iconWidth: iconWidth) - .transition(.opacity) - } - - if hasVisibleSidebarStatuses { - Spacer().frame(height: OmiSpacing.lg) - permissionStatusSection - } - - Spacer().frame(height: OmiSpacing.lg) - Rectangle() - .fill(Ink.rowFillHover) - .frame(height: 1) - - Spacer().frame(height: OmiSpacing.md) - profileMenuButton - - Spacer().frame(height: OmiSpacing.sm) - } - .padding(.horizontal, isCollapsed ? OmiSpacing.sm : OmiSpacing.lg) - .frame(maxHeight: .infinity) - } - .frame(maxWidth: currentWidth + dragOffset, maxHeight: .infinity, alignment: .top) - .background(Color.clear) - .omiAnimation(.easeInOut(duration: 0.2), value: isCollapsed) - - // Drag handle - Rectangle() - .fill(Color.clear) - .frame(width: 8) - .contentShape(Rectangle()) - .gesture( - DragGesture() - .updating($isDragging) { _, state, _ in - state = true - } - .onChanged { value in - let newWidth = currentWidth + value.translation.width - if newWidth < (collapsedWidth + expandedWidth) / 2 { - if !isCollapsed { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed = true - } - } - } else { - if isCollapsed { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed = false - } - } - } - } - ) - .onHover { hovering in - if hovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - } - .frame(width: currentWidth) - .onAppear { - syncMonitoringState() - appState.checkAllPermissions() - updatePermissionPulse(hasPermissionDenied) - } - .onChange(of: currentTierLevel) { _, newTier in - // Redirect if current page became locked after tier change - if let currentItem = SidebarNavItem(rawValue: selectedIndex), - newTier != 0 && newTier < currentItem.requiredTier, - selectedIndex != SidebarNavItem.settings.rawValue - && selectedIndex != SidebarNavItem.permissions.rawValue - { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - } - .onChange(of: selectedIndex) { _, _ in - // Check tier eligibility on page navigation (at most once per day) - Task { - await TierManager.shared.checkTierIfNeeded() - } - } - .onChange(of: hasPermissionDenied) { _, denied in - updatePermissionPulse(denied) - } - .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { - notification in - syncMonitoringState() - let isNowMonitoring = - (notification.userInfo?["isMonitoring"] as? Bool) - ?? ProactiveAssistantsPlugin.shared.isMonitoring - if isNowMonitoring { - // Reset retry counter on successful start - monitoringAutoRestartAttempts = 0 - } else if screenAnalysisEnabled && !isTogglingMonitoring - && monitoringAutoRestartAttempts < maxAutoRestartAttempts - { - // Auto-restart: monitoring stopped but user's setting says it should be on. - // Try to restart after a delay (handles transient failures, sleep/wake, etc.) - monitoringAutoRestartAttempts += 1 - let attempt = monitoringAutoRestartAttempts - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - let plugin = ProactiveAssistantsPlugin.shared - guard !plugin.isMonitoring && screenAnalysisEnabled else { return } - plugin.refreshScreenRecordingPermission() - if plugin.hasScreenRecordingPermission { - log( - "SidebarView: Auto-restarting monitoring (attempt \(attempt)/\(maxAutoRestartAttempts))" - ) - plugin.startMonitoring { success, _ in - if !success { - log("SidebarView: Auto-restart attempt \(attempt) failed") - } - } - } - } - } - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - // Refresh permissions when app becomes active (user may have changed them in System Settings) - appState.checkAllPermissions() - } - .onReceive(NotificationCenter.default.publisher(for: .rewindPageDidLoad)) { _ in - isRewindPageLoading = false - } - .onReceive(NotificationCenter.default.publisher(for: .conversationsPageDidLoad)) { _ in - isConversationsPageLoading = false - } - .onReceive(NotificationCenter.default.publisher(for: .tasksPageDidLoad)) { _ in - isTasksPageLoading = false - } - .onReceive(NotificationCenter.default.publisher(for: .appsPageDidLoad)) { _ in - isAppsPageLoading = false - } - } - - // MARK: - Header Section (Logo + Collapse Button on same row) - private var headerSection: some View { - HStack(spacing: OmiSpacing.md) { - // SBLogo resolves the packaged mark through signed-app, development, and - // preview layouts, and retains the eight-dot Omi silhouette if an asset - // host is incomplete. Never substitute a generic solid circle here. - SBLogo(size: iconWidth, tint: Ink.primary) - - if !isCollapsed { - // Brand name - Text(UpdateChannel.appDisplayName) - .scaledFont(size: OmiType.heading, weight: .bold) - .foregroundColor(Ink.primary) - .tracking(-0.5) - - Spacer() - - // Collapse button - Button(action: { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed.toggle() - } - }) { - Image(systemName: "sidebar.left") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - .help("Collapse sidebar") - } else { - // When collapsed, just show collapse button below logo - Spacer() - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - } - - // Collapse button for collapsed state (shown separately) - private var collapsedExpandButton: some View { - Button(action: { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed.toggle() - } - }) { - Image(systemName: "sidebar.left") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: iconWidth) - } - .buttonStyle(.plain) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .help("Expand sidebar") - } - - private var proBadge: some View { - Text("Pro") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.hairline) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius, style: .continuous) - .fill(Ink.rowFillHover) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius, style: .continuous) - .strokeBorder(Ink.separator, lineWidth: 1) - ) - ) - } - - // MARK: - Profile Menu - - private var shouldShowScreenRecordingStatus: Bool { - appState.hasScreenRecordingPermission || !appState.hasScreenRecordingPermission - || appState.isScreenCaptureKitBroken - || appState.isScreenRecordingStale - } - - private var shouldShowMicrophoneStatus: Bool { - appState.hasMicrophonePermission || !appState.hasMicrophonePermission - } - - private var shouldShowAccessibilityStatus: Bool { - !appState.hasAccessibilityPermission || appState.isAccessibilityBroken - } - - private var hasVisibleSidebarStatuses: Bool { - shouldShowScreenRecordingStatus || shouldShowMicrophoneStatus || shouldShowAccessibilityStatus - } - - private var profileDisplayName: String { - let displayName = AuthService.shared.displayName.trimmingCharacters(in: .whitespacesAndNewlines) - if !displayName.isEmpty { - return displayName - } - - if let email = authState.userEmail, !email.isEmpty { - return email.components(separatedBy: "@").first ?? email - } - - return "Profile" - } - - private var profileInitials: String { - let parts = - profileDisplayName - .split(separator: " ") - .prefix(2) - .compactMap { $0.first } - - let initials = String(parts) - if !initials.isEmpty { - return initials.uppercased() - } - - return "OM" - } - - private var profileMenuButton: some View { - Button { - // Straight to Settings — no intermediate menu popover. - selectedIndex = SidebarNavItem.settings.rawValue - } label: { - HStack(spacing: isCollapsed ? 0 : 10) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFillHover) - .frame(width: 34, height: 34) - - Image(systemName: "gearshape.fill") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.secondary) - } - - if !isCollapsed { - Text("Settings") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.primary) - .lineLimit(1) - - Spacer(minLength: 8) - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(maxWidth: .infinity, alignment: isCollapsed ? .center : .leading) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill( - selectedIndex == SidebarNavItem.settings.rawValue || isProfileButtonHovered - ? Ink.rowFillHover : Color.clear - ) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - isProfileButtonHovered = hovering - } - .help("Open Settings") - } - - // MARK: - Permission Warning Button - - // Check if any permission is specifically denied (not just missing) - private var hasPermissionDenied: Bool { - appState.isMicrophonePermissionDenied() || appState.isScreenRecordingPermissionDenied() - || appState.isAccessibilityPermissionDenied() - } - - @State private var permissionPulse = false - - private func updatePermissionPulse(_ denied: Bool) { - if denied { - OmiMotion.withGated(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - permissionPulse = true - } - } else { - permissionPulse = false - } - } - - private var permissionStatusSection: some View { - VStack(spacing: OmiSpacing.xs) { - if shouldShowScreenRecordingStatus { - screenRecordingPermissionRow(isExpanded: !isCollapsed) - } - - if shouldShowMicrophoneStatus { - microphonePermissionRow(isExpanded: !isCollapsed) - } - - if shouldShowAccessibilityStatus { - accessibilityPermissionRow(isExpanded: !isCollapsed) - } - } - } - - @ViewBuilder - private func screenRecordingPermissionRow(isExpanded: Bool) -> some View { - let isDenied = appState.isScreenRecordingPermissionDenied() - let isBroken = appState.isScreenCaptureKitBroken // TCC yes but SCK no - let isStale = appState.isScreenRecordingStale // Developer signing changed - let isToggleable = appState.hasScreenRecordingPermission && !isBroken && !isStale - let isActive = screenAnalysisEnabled && isToggleable - let needsReset = isBroken // Show reset when broken (not stale — stale needs toggle off/on) - let color: Color = - isToggleable - ? Ink.secondary - : Ink.errorRed // Denied, broken or stale read as one failure. - let titleColor: Color = - isToggleable ? Ink.secondary : color - - let row = HStack(spacing: OmiSpacing.sm) { - Image( - systemName: (isDenied || isBroken || isStale) - ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" - ) - .scaledFont(size: OmiType.subheading) - .foregroundColor(color) - .frame(width: iconWidth) - .scaleEffect(permissionPulse && (isDenied || isBroken || isStale) ? 1.1 : 1.0) - - if isExpanded { - Text("Screen Recording") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(titleColor) - .lineLimit(1) - - Spacer() - - if isToggleable { - statusAccessoryToggle(isOn: isActive) - } else { - Button(action: { - if isStale { - // Stale/corrupted TCC — navigate to Permissions page with full instructions - selectedIndex = SidebarNavItem.permissions.rawValue - } else if needsReset { - // Track reset button click - AnalyticsManager.shared.screenCaptureResetClicked(source: "sidebar_button") - // Reset and restart to fix broken ScreenCaptureKit state - ScreenCaptureService.resetScreenCapturePermissionAndRestart() - } else { - ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() - // Track attempt — if still not granted on next check, show recovery instructions - appState.screenRecordingGrantAttempts += 1 - } - }) { - Text(isStale ? "Fix" : (needsReset ? "Reset" : "Grant")) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(.white) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(color) - ) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xs) - .frame(minHeight: 40) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill( - isToggleable - ? Color.clear - : color.opacity(permissionPulse && (isDenied || isBroken || isStale) ? 0.25 : 0.15) - ) - ) - .help( - isToggleable - ? (isActive - ? "Click to turn off Screen Recording monitoring" - : "Click to turn on Screen Recording monitoring") - : (isExpanded - ? "" - : (isStale - ? "Screen Recording needs re-enabling" - : (isBroken ? "Screen Recording needs reset" : "Screen Recording permission required"))) - ) - - if isToggleable { - Button(action: { - toggleMonitoring(enabled: !screenAnalysisEnabled) - }) { - row - } - .buttonStyle(.plain) - } else { - row - } - } - - @ViewBuilder - private func microphonePermissionRow(isExpanded: Bool) -> some View { - let isDenied = appState.isMicrophonePermissionDenied() - let isToggleable = appState.hasMicrophonePermission - let isActive = audioRecordingModeRaw != AssistantSettings.AudioRecordingMode.off.rawValue && isToggleable - let color: Color = - isToggleable - ? Ink.secondary - : Ink.errorRed - let titleColor: Color = - isToggleable ? Ink.secondary : color - - let row = HStack(spacing: OmiSpacing.sm) { - Image(systemName: isDenied ? "mic.slash.fill" : "mic.fill") - .scaledFont(size: OmiType.subheading) - .foregroundColor(color) - .frame(width: iconWidth) - .scaleEffect(permissionPulse && isDenied ? 1.1 : 1.0) - - if isExpanded { - Text("Microphone") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(titleColor) - .lineLimit(1) - - Spacer() - - if isToggleable { - statusAccessoryToggle(isOn: isActive) - } else { - Button(action: { - if isDenied { - // Go to permissions page for reset options - selectedIndex = SidebarNavItem.permissions.rawValue - } else { - // Request permission directly - appState.requestMicrophonePermission() - } - }) { - Text(isDenied ? "Fix" : "Grant") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(.white) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(color) - ) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xs) - .frame(minHeight: 40) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill( - isToggleable - ? Color.clear - : color.opacity(permissionPulse && isDenied ? 0.25 : 0.15) - ) - ) - .help( - isToggleable - ? (isActive - ? "Click to turn off Microphone transcription" - : "Click to turn on Microphone transcription") - : (isExpanded ? "" : "Microphone permission required") - ) - - if isToggleable { - Button(action: { - toggleTranscription(enabled: audioRecordingModeRaw == AssistantSettings.AudioRecordingMode.off.rawValue) - }) { - row - } - .buttonStyle(.plain) - } else { - row - } - } - - private func accessibilityPermissionRow(isExpanded: Bool) -> some View { - let isDenied = appState.isAccessibilityPermissionDenied() - let isBroken = appState.isAccessibilityBroken // TCC yes but AX calls fail - let needsReset = isBroken // Show reset when broken - let color: Color = Ink.errorRed - - return HStack(spacing: OmiSpacing.sm) { - Image(systemName: (isDenied || isBroken) ? "hand.raised.slash.fill" : "hand.raised.fill") - .scaledFont(size: OmiType.subheading) - .foregroundColor(color) - .frame(width: iconWidth) - .scaleEffect(permissionPulse && (isDenied || isBroken) ? 1.1 : 1.0) - - if isExpanded { - Text("Accessibility") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(color) - .lineLimit(1) - - Spacer() - - Button(action: { - if needsReset { - // Reset and restart to fix broken accessibility state - appState.resetAccessibilityPermissionAndRestart() - } else { - // Trigger the permission request, which will also open settings - appState.triggerAccessibilityPermission() - } - }) { - Text(needsReset ? "Reset" : (isDenied ? "Fix" : "Grant")) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(.white) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(color) - ) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xs) - .frame(minHeight: 40) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(color.opacity(permissionPulse && (isDenied || isBroken) ? 0.25 : 0.15)) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .stroke(color.opacity(0.3), lineWidth: (isDenied || isBroken) ? 2 : 1) - ) - ) - .help( - isExpanded - ? "" : (isBroken ? "Accessibility needs reset" : "Accessibility permission required")) - } - - private func statusAccessoryToggle(isOn: Bool) -> some View { - ZStack(alignment: isOn ? .trailing : .leading) { - Capsule() - .fill( - isOn - ? Ink.listeningGreen.opacity(0.9) : Ink.rowFillHover - ) - .frame(width: 30, height: 18) - - Circle() - .fill(.white.opacity(isOn ? 0.98 : 0.92)) - .frame(width: 14, height: 14) - .padding(OmiSpacing.hairline) - } - .padding(.trailing, OmiSpacing.hairline) - .omiAnimation(.easeInOut(duration: 0.16), value: isOn) - .accessibilityHidden(true) - } - - // MARK: - Toggle Handlers - - private func toggleTranscription(enabled: Bool) { - // Check microphone permission - if enabled && !appState.hasMicrophonePermission { - return - } - - // Show loading immediately - isTogglingTranscription = true - - // Track setting change - AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) - - let mode: AssistantSettings.AudioRecordingMode = enabled ? .onlyMeetings : .off - audioRecordingModeRaw = mode.rawValue - AssistantSettings.shared.audioRecordingMode = mode - - // Small delay to show the loading state visually - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingTranscription = false - } - } - - private func toggleMonitoring(enabled: Bool) { - if enabled { - // Refresh permission cache before checking (may be stale after user granted access) - ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() - } - - if enabled && !ProactiveAssistantsPlugin.shared.hasScreenRecordingPermission { - isMonitoring = false - ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() - return - } - - // Show loading immediately and update state optimistically - isTogglingMonitoring = true - isMonitoring = enabled - - // Track setting change - AnalyticsManager.shared.settingToggled(setting: "monitoring", enabled: enabled) - - // Persist the setting - screenAnalysisEnabled = enabled - AssistantSettings.shared.screenAnalysisEnabled = enabled - - if enabled { - ProactiveAssistantsPlugin.shared.startMonitoring { success, _ in - DispatchQueue.main.async { - isTogglingMonitoring = false - if !success { - // Revert on failure including persistent setting - isMonitoring = false - screenAnalysisEnabled = false - AssistantSettings.shared.screenAnalysisEnabled = false - } - } - } - } else { - ProactiveAssistantsPlugin.shared.stopMonitoring() - // Small delay to show the loading state visually - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingMonitoring = false - } - } - } - - private func syncMonitoringState() { - let pluginState = ProactiveAssistantsPlugin.shared.isMonitoring - isMonitoring = pluginState - // Don't touch screenAnalysisEnabled here — it represents the user's preference, - // not the current monitoring state. Auto-restart below will handle recovery. - } - - // MARK: - Page Loading Helpers - - private func pageLoadingState(for item: SidebarNavItem) -> Bool { - switch item { - case .tasks: return isTasksPageLoading - case .apps: return isAppsPageLoading - default: return false - } - } - - private func setPageLoading(for item: SidebarNavItem, loading: Bool) { - switch item { - case .tasks: isTasksPageLoading = loading - case .apps: isAppsPageLoading = loading - default: break - } - } - - // MARK: - Tier Unlock Animation - -} - -// MARK: - Nav Item View -struct NavItemView: View { - let icon: String - let label: String - let isSelected: Bool - let isCollapsed: Bool - let iconWidth: CGFloat - var statusColor: Color? = nil - var isLoading: Bool = false - var isLocked: Bool = false - var lockTooltip: String? = nil - var onUnlock: (() -> Void)? = nil - let onTap: () -> Void - - @State private var isHovered = false - @State private var isLockHovered = false - - /// Foreground color for icon and text when locked - private var lockedColor: Color { Ink.secondary.opacity(0.45) } - - var body: some View { - HStack(spacing: OmiSpacing.md) { - ZStack(alignment: .topTrailing) { - if isLoading && !isLocked { - ProgressView() - .scaleEffect(0.5) - .frame(width: iconWidth, height: 17) - } else { - Image(systemName: icon) - .scaledFont(size: OmiType.subheading) - .foregroundColor( - isLocked ? lockedColor : (isSelected ? Ink.primary : Ink.secondary) - ) - .frame(width: iconWidth) - } - - // Status indicator when collapsed, hidden when locked - if isCollapsed, let color = statusColor, !isLocked { - Circle() - .fill(color) - .frame(width: 8, height: 8) - .offset(x: 4, y: -4) - } - - // Lock badge when collapsed — clickable - if isCollapsed && isLocked { - lockIcon(size: 8) - .offset(x: 4, y: -4) - } - } - - if !isCollapsed { - Text(label) - .scaledFont(size: OmiType.body, weight: isSelected ? .medium : .regular) - .foregroundColor( - isLocked ? lockedColor : (isSelected ? Ink.primary : Ink.secondary)) - - Spacer() - - if isLocked { - // Clickable lock icon - lockIcon(size: 10) - } else { - // Status indicator when expanded (for Focus) - if let color = statusColor { - Circle() - .fill(color) - .frame(width: 8, height: 8) - } - - // Badge count now shown on icon (see ZStack above) - } - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill( - isLocked - ? Color.clear - : (isSelected - ? Ink.rowFillHover - : (isHovered ? Ink.rowFill : Color.clear)) - ) - ) - .onTapGesture { - guard !isLocked else { return } - log("SIDEBAR: NavItem '\(label)' tapped at mouse position: \(NSEvent.mouseLocation)") - onTap() - } - .onHover { hovering in - isHovered = isLocked ? false : hovering - } - .padding(.bottom, OmiSpacing.hairline) - .help(isCollapsed ? label : "") - .accessibilityLabel(label) - .accessibilityAddTraits(.isButton) - .accessibilityIdentifier( - "sidebar_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))") - } - - /// Lock icon that reacts on hover and unlocks on click - private func lockIcon(size: CGFloat) -> some View { - Image(systemName: isLockHovered ? "lock.open.fill" : "lock.fill") - .scaledFont(size: size) - .foregroundColor(isLockHovered ? Ink.primary : lockedColor) - .padding(OmiSpacing.xxs) - .contentShape(Rectangle()) - .onHover { hovering in - OmiMotion.withGated(.easeInOut(duration: 0.15)) { - isLockHovered = hovering - } - } - .onTapGesture { - onUnlock?() - } - .help("Click to unlock") - } -} - -// MARK: - Nav Item With Status Icon View -/// Navigation item that shows status via icon color/animation instead of a toggle -struct NavItemWithStatusView: View { - let icon: String - let label: String - let isSelected: Bool - let isCollapsed: Bool - let iconWidth: CGFloat - let isOn: Bool - let isToggling: Bool - var isPageLoading: Bool = false - let onTap: () -> Void - let onToggle: () -> Void - - // Optional audio levels for conversations - var micLevel: Float = 0 - var systemLevel: Float = 0 - var showAudioBars: Bool = false - - // Optional Rewind pulsing icon - var showRewindIcon: Bool = false - - @State private var isHovered = false - - /// Icon color based on state - private var iconColor: Color { - if isOn { - return isSelected ? Ink.primary : Ink.secondary - } else { - return Ink.errorRed - } - } - - var body: some View { - HStack(spacing: OmiSpacing.md) { - // Icon area - tappable to toggle - ZStack(alignment: .topTrailing) { - // Show loading spinner in place of icon when loading - if isToggling || isPageLoading { - ProgressView() - .scaleEffect(0.5) - .frame(width: iconWidth, height: 17) - } else if showAudioBars && isOn { - // Show audio bars when active and enabled for conversations - SidebarAudioLevelIcon( - micLevel: micLevel, - systemLevel: systemLevel, - isActive: true - ) - .frame(width: iconWidth) - } else if showRewindIcon { - // Show pulsing Rewind icon - SidebarRewindIcon(isActive: isOn) - .frame(width: iconWidth) - } else { - Image(systemName: icon) - .scaledFont(size: OmiType.subheading) - .foregroundColor(iconColor) - .frame(width: iconWidth) - } - - // Status indicator when collapsed and off - if isCollapsed && !isOn && !isToggling && !isPageLoading { - Circle() - .fill(Ink.errorRed) - .frame(width: 6, height: 6) - .offset(x: 3, y: -3) - } - } - .contentShape(Rectangle()) - .onTapGesture { - if !isToggling { - onToggle() - } - } - - if !isCollapsed { - Text(label) - .scaledFont(size: OmiType.body, weight: isSelected ? .medium : .regular) - .foregroundColor(isSelected ? Ink.primary : Ink.secondary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - - Spacer(minLength: 4) - } - } - .padding(.leading, OmiSpacing.md) - .padding(.trailing, isCollapsed ? OmiSpacing.md : OmiSpacing.sm) - .padding(.vertical, OmiSpacing.md) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill( - isSelected - ? Ink.rowFillHover - : (isHovered ? Ink.rowFill : Color.clear) - ) - ) - .onTapGesture { - log( - "SIDEBAR: NavItemWithStatus '\(label)' row tapped at mouse position: \(NSEvent.mouseLocation)" - ) - onTap() - } - .onHover { hovering in - isHovered = hovering - } - .padding(.bottom, OmiSpacing.hairline) - .help( - isCollapsed - ? "\(label) (\(isOn ? "On" : "Off")) - Click icon to toggle" : "Click icon to toggle" - ) - .accessibilityLabel("\(label) (\(isOn ? "On" : "Off"))") - .accessibilityAddTraits(.isButton) - .accessibilityIdentifier( - "sidebar_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))") - } -} - -// MARK: - Custom Sidebar Toggle -struct SidebarToggle: View { - @Binding var isOn: Bool - - private let width: CGFloat = 36 - private let height: CGFloat = 20 - private let circleSize: CGFloat = 16 - private let padding: CGFloat = 2 - - var body: some View { - ZStack(alignment: isOn ? .trailing : .leading) { - // Track — primary ink on, outlined wash off: "off" rests, it does not fail. - Capsule() - .fill(isOn ? Ink.primary : Ink.rowFillHover) - .overlay(Capsule().strokeBorder(isOn ? Color.clear : Ink.hairline, lineWidth: 1)) - .frame(width: width, height: height) - - // Thumb — the label ladder inverted on the filled track, the ink itself off it. - Circle() - .fill(isOn ? Ink.surface : Ink.primary) - .frame(width: circleSize, height: circleSize) - .padding(padding) - } - .omiAnimation(.easeInOut(duration: 0.15), value: isOn) - .onTapGesture { - isOn.toggle() - } - } -} - -// MARK: - Sidebar Audio Level Icon -/// Compact audio level indicator that fits in the sidebar icon space -struct SidebarAudioLevelIcon: View { - let micLevel: Float - let systemLevel: Float - let isActive: Bool - - private let barCount = 4 - private let iconSize: CGFloat = 17 - - /// Combined audio level (max of mic and system) - private var combinedLevel: Float { - max(micLevel, systemLevel) - } - - var body: some View { - HStack(spacing: OmiSpacing.hairline) { - ForEach(0..<barCount, id: \.self) { index in - SidebarAudioBar( - level: combinedLevel, - index: index, - totalBars: barCount, - isActive: isActive - ) - } - } - .frame(width: iconSize, height: iconSize) - } -} - -private struct SidebarAudioBar: View { - let level: Float - let index: Int - let totalBars: Int - let isActive: Bool - - private let minHeight: CGFloat = 4 - private let maxHeight: CGFloat = 14 - private let barWidth: CGFloat = 3 - - private var barHeight: CGFloat { - guard isActive else { return minHeight } - - // Boost low levels for visibility - let boostedLevel = pow(CGFloat(level), 0.5) * 2.0 - let clampedLevel = min(1.0, boostedLevel) - - // Center bars slightly taller - let centerOffset = - abs(CGFloat(index) - CGFloat(totalBars - 1) / 2.0) / (CGFloat(totalBars) / 2.0) - let variation = 1.0 - (centerOffset * 0.3) - - let scaledLevel = clampedLevel * variation - // Deterministic per-bar variation (avoid CGFloat.random which causes layout churn) - let hash = sin(CGFloat(index) * 1.618 + 0.5) - let deterministicVariation = 0.9 + 0.2 * (hash * 0.5 + 0.5) - - let height = minHeight + (maxHeight - minHeight) * scaledLevel * deterministicVariation - return max(minHeight, min(maxHeight, height)) - } - - private var barColor: Color { - guard isActive else { return Ink.secondary.opacity(0.5) } - - let boostedLevel = min(1.0, pow(CGFloat(level), 0.5) * 2.0) - if boostedLevel > 0.5 { - return Ink.primary - } else if boostedLevel > 0.15 { - return Ink.primary - } else if boostedLevel > 0.02 { - return Ink.secondary - } - return Ink.secondary - } - - var body: some View { - RoundedRectangle(cornerRadius: 1) - .fill(barColor) - .frame(width: barWidth, height: barHeight) - } -} - -// MARK: - Sidebar Rewind Icon -/// Animated recording indicator for Rewind when capturing -struct SidebarRewindIcon: View { - let isActive: Bool - - private let iconSize: CGFloat = 17 - - @State private var isPulsing = false - - var body: some View { - ZStack { - // Outer pulsing ring when active - if isActive { - Circle() - .stroke(Ink.primary.opacity(0.3), lineWidth: 2) - .frame(width: iconSize, height: iconSize) - .scaleEffect(isPulsing ? 1.4 : 1.0) - .opacity(isPulsing ? 0 : 0.8) - } - - // Inner recording dot - Circle() - .fill(isActive ? Ink.primary : Ink.errorRed) - .frame(width: isActive ? 10 : 8, height: isActive ? 10 : 8) - } - .frame(width: iconSize, height: iconSize) - .onAppear { - if isActive { - startPulsing() - } - } - .onChange(of: isActive) { _, newValue in - if newValue { - startPulsing() - } else { - isPulsing = false - } - } - } - - private func startPulsing() { - OmiMotion.withGated(.easeOut(duration: 1.0).repeatForever(autoreverses: false)) { - isPulsing = true - } - } -} - -// MARK: - Bottom Nav Item View -struct BottomNavItemView: View { - let icon: String - let label: String - let isCollapsed: Bool - let iconWidth: CGFloat - let onTap: () -> Void - - @State private var isHovered = false - - var body: some View { - HStack(spacing: OmiSpacing.md) { - Image(systemName: icon) - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: iconWidth) - - if !isCollapsed { - Text(label) - .scaledFont(size: OmiType.body, weight: .regular) - .foregroundColor(Ink.secondary) - - Spacer() - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(isHovered ? Ink.rowFillHover : Color.clear) - ) - .onTapGesture { - log("SIDEBAR: BottomNavItem '\(label)' tapped at mouse position: \(NSEvent.mouseLocation)") - onTap() - } - .onHover { hovering in - isHovered = hovering - } - .padding(.bottom, OmiSpacing.hairline) - .help(isCollapsed ? label : "") - .accessibilityLabel(label) - .accessibilityAddTraits(.isButton) - .accessibilityIdentifier( - "sidebar_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))") - } -} - -// MARK: - Audio Level Nav Item Wrapper - -/// Isolates AudioLevelMonitor observation so audio level changes -/// only re-render this small wrapper, not the entire SidebarView. -private struct AudioLevelNavItem: View { - let icon: String - let label: String - let isSelected: Bool - let isCollapsed: Bool - let iconWidth: CGFloat - let isOn: Bool - let isToggling: Bool - var isPageLoading: Bool = false - let onTap: () -> Void - let onToggle: () -> Void - - @ObservedObject private var audioLevels = AudioLevelMonitor.shared - - var body: some View { - NavItemWithStatusView( - icon: icon, - label: label, - isSelected: isSelected, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isOn: isOn, - isToggling: isToggling, - isPageLoading: isPageLoading, - onTap: onTap, - onToggle: onToggle, - micLevel: audioLevels.microphoneLevel, - systemLevel: audioLevels.systemLevel, - showAudioBars: true - ) - } -} - -// MARK: - Cached Omi Device Image - -/// Cache the Omi device WebP image so it's decoded once, not on every SwiftUI body evaluation. -/// The original 1383x1383 WebP was being re-decoded by CoreAnimation every render frame. -enum OmiDeviceImage { - @MainActor static let shared: NSImage? = { - guard - let url = Bundle.resourceBundle.url( - forResource: "omi-with-rope-no-padding", withExtension: "webp") - else { - return nil - } - return NSImage(contentsOf: url) - }() -} - -// MARK: - App Nav Rail (Second Brain) - -/// The thin, always-present left navigation rail for the redesigned app shell. -/// Lives beside every page (not just Home) so you can move between Home, the -/// memory/task surfaces and Apps without bouncing back through Home. Settings -/// sits at the foot. Styled with the SB ink system so it matches the sign-in / -/// onboarding aesthetic. -struct AppNavRail: View { - @Binding var selectedIndex: Int - @State private var isExpanded = false - - /// Rail width at rest (icons only) and expanded (icons + labels). - static let restWidth: CGFloat = 60 - static let expandedWidth: CGFloat = 216 - - private struct RailItem: Hashable { - let index: Int - let title: String - let icon: String - } - - /// Simplified, merged navigation: "Memory" folds in Conversations + Memories, - /// and Rewind moved off the rail (it opens from a right-click on Capture). - /// Each entry drives selectedIndex. - private var items: [RailItem] { - [ - RailItem(index: SidebarNavItem.dashboard.rawValue, title: "Home", icon: "house.fill"), - RailItem(index: SidebarNavItem.conversations.rawValue, title: "Memory", icon: "brain"), - RailItem(index: SidebarNavItem.tasks.rawValue, title: "Tasks", icon: "checklist"), - RailItem(index: SidebarNavItem.apps.rawValue, title: "Apps", icon: "puzzlepiece.fill"), - ] - } - - var body: some View { - VStack(spacing: 4) { - ForEach(items, id: \.self) { item in - AppNavRailButton( - icon: item.icon, - title: item.title, - isSelected: selectedIndex == item.index, - isExpanded: isExpanded, - action: { select(item.index, title: item.title) } - ) - } - - Spacer(minLength: 12) - - AppNavRailButton( - icon: SidebarNavItem.settings.icon, - title: SidebarNavItem.settings.title, - isSelected: selectedIndex == SidebarNavItem.settings.rawValue, - isExpanded: isExpanded, - action: { select(SidebarNavItem.settings.rawValue, title: "Settings") } - ) - } - .padding(.vertical, 16) - .padding(.horizontal, 10) - .frame(width: isExpanded ? Self.expandedWidth : Self.restWidth, alignment: .leading) - .frame(maxHeight: .infinity, alignment: .top) - // No ground of its own — the shell's glass is under this. Expanded, the rail - // covers content, so it becomes real glass with the one ambient shadow. - .background { if isExpanded { Color.clear.glassFloatingBar(cornerRadius: PageGlass.cardRadius) } } - .overlay(alignment: .trailing) { - Rectangle().fill(Ink.separator).frame(width: 1).opacity(isExpanded ? 0 : 1) - } - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.18)) { isExpanded = hovering } - } - } - - private func select(_ index: Int, title: String) { - guard selectedIndex != index else { return } - selectedIndex = index - AnalyticsManager.shared.tabChanged(tabName: title) - } -} - -private struct AppNavRailButton: View { - let icon: String - let title: String - let isSelected: Bool - let isExpanded: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 15, weight: .medium)) - .frame(width: 40, height: 40) - - if isExpanded { - Text(title) - .geist(size: 14, weight: isSelected ? .medium : .regular) - .lineLimit(1) - .fixedSize() - } - } - .foregroundStyle(isSelected || isHovering ? Ink.primary : Ink.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 40) - // A stadium, like every other pressable thing in this system. - .background(GlassPillBackground(isSelected: isSelected, isHovering: isHovering)) - .contentShape(Capsule(style: .continuous)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift index 227433ef64a..e00b563263f 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift @@ -1224,7 +1224,8 @@ struct OnboardingChatView: View { title: "Need help with \(permLabel)?", message: helpMessage, assistantId: "onboarding", - sound: .none + sound: .none, + kind: .onboarding ) } } diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift index 8aedb524922..7067cacc528 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift @@ -44,7 +44,10 @@ final class TaskChatCoordinator: ObservableObject { private var suppressUnreadPersistence = false private var isResettingOwnerProjection = false - private let chatProvider: ChatProvider + /// The one provider (INV-6). Exposed so the task panel can build the same + /// content-block context every other Chat surface uses, without a second + /// provider or transcript. + let chatProvider: ChatProvider private let workstreamAPI: any TaskWorkstreamAPI private let persistWorkstreamLink: @MainActor (String, String, String, LocalMutationAuthorization) async -> Void private let ownerIDProvider: @MainActor () -> String? diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift index aefe3354369..a2e056710c8 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift @@ -417,6 +417,9 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { message: metadata.message, assistantId: metadata.assistantId, sound: .none, + // Explicit at the producer edge — the assistant's own declared category. + // `FloatingBarNotification` no longer derives one for a caller that omits it. + kind: ProactiveNotificationKind.from(assistantId: metadata.assistantId), context: metadata.context, jitFeedbackContext: feedbackContext, isPersistent: true, @@ -715,6 +718,8 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { message: message, assistantId: assistantId, sound: sound, + // The same category this delivery was already gated on a few lines up. + kind: ProactiveNotificationKind.from(assistantId: assistantId), context: context, action: action, jitFeedbackContext: jitFeedbackContext, @@ -1051,7 +1056,9 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { case .insight, .resurface, .goal: return insightEnabled case .memory: return memoryEnabled case .integration: return integrationEnabled - case .general: return true + // Functional system notices and the two never-journaled product cards sit + // outside the five-category taxonomy and are ungated by it. + case .general, .functional, .trial, .onboarding: return true } } diff --git a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift index 4e4541fe975..c80f8aaf79c 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift @@ -71,8 +71,14 @@ enum ChatFirstBlockToolExecutor { return ChatToolExecutor.authorizedOwnerChangedResult() } guard let journalBlocks = ChatFirstBlockWire.journalBlocks(from: receipt) else { + log( + "ChatFirstBlockToolExecutor: backend rejected \(backendBlocks.count) block(s) " + + "[\(backendBlocks.compactMap { $0["type"] as? String }.joined(separator: ","))]") return #"{"ok":false,"error":{"code":"chat_first_blocks_rejected"}}"# } + log( + "ChatFirstBlockToolExecutor: appending \(journalBlocks.count) block(s) " + + "[\(journalBlocks.compactMap { $0["type"] as? String }.joined(separator: ","))]") let journalBlocksData = try JSONSerialization.data(withJSONObject: journalBlocks) guard let journalBlocksJSON = String(data: journalBlocksData, encoding: .utf8) else { return #"{"ok":false,"error":{"code":"chat_first_blocks_unavailable"}}"# @@ -93,7 +99,9 @@ enum ChatFirstBlockToolExecutor { backendBlocks.compactMap { citationSelection(from: $0) }.filter { !$0.sourceID.isEmpty }, runID: runID, attemptID: attemptID) - return #"{"ok":true,"rendered":#(journalBlocks.count)}"# + // `#(...)` is not interpolation in a raw string — the count was being + // reported to the model as the literal text `#(journalBlocks.count)`. + return #"{"ok":true,"rendered":\#(journalBlocks.count)}"# } catch { guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider+JournalProjection.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider+JournalProjection.swift index 1368679217e..cd52efd48d7 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider+JournalProjection.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider+JournalProjection.swift @@ -88,6 +88,7 @@ extension ChatProvider { if updatedMessages.map(\.id) != orderBeforeCanonicalSort { divergences.insert(.ordering) } + Self.inheritCitationsAcrossTurns(&updatedMessages) messages = updatedMessages flushPendingMessageRatings() Task { await bindKindOnlyCitationsIfNeeded() } @@ -105,6 +106,23 @@ extension ChatProvider { projectJournalTurns([turn]) } + /// A settled follow-up that cites a number it never retrieved itself is + /// pointing at an earlier turn's list (`ChatCitationMarkup.inheritedReferences`). + /// The binding is a projection over the journal, not a row written to it: the + /// references it borrows were already persisted on the turn that earned them, + /// and re-deriving them here is what lets restored history open the same + /// source the reader could open live. + static func inheritCitationsAcrossTurns(_ messages: inout [ChatMessage]) { + for index in messages.indices where messages[index].sender == .ai && !messages[index].isStreaming { + let inherited = ChatCitationMarkup.inheritedReferences( + citedIn: messages[index], + resolved: messages[index].inlineCitationReferences, + earlierTurns: Array(messages[..<index])) + guard !inherited.isEmpty else { continue } + messages[index].persistCitedReferences(from: inherited) + } + } + /// Local memories/conversations/tasks used to bind kind-only labels such as `[memory]` when the /// model copied a category name instead of the numeric marker. func kindCitationLookupReferences() async -> [ChatCitationReference] { @@ -168,13 +186,19 @@ extension ChatProvider { retrievedReferences: turnReferences, fallbackText: queryText) messages[index].isStreaming = false + // A number this turn never assigned is one the reader was shown a turn ago. + let inheritedReferences = ChatCitationMarkup.inheritedReferences( + citedIn: messages[index], + resolved: turnReferences, + earlierTurns: Array(messages[..<index])) + let bindableReferences = turnReferences + inheritedReferences let bindBase: [ChatCitationReference] if messages[index].hasKindOnlyCitationMarkers { bindBase = ChatCitationReference.appendingLookup( await kindCitationLookupReferences(), - to: turnReferences) + to: bindableReferences) } else { - bindBase = turnReferences + bindBase = bindableReferences } await applyKindOnlyCitationBinding(to: messageId, base: bindBase) guard let current = messages.first(where: { $0.id == messageId }) else { return queryText } diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 3b0541ad71b..fb513c0b750 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1924,6 +1924,12 @@ class ChatProvider: ObservableObject { for: surface, ownerID: ownerID ) + if surface.surfaceKind == "main_chat" { + log( + "ChatProvider: resolving main_chat session chatFirstCapability=" + + (projection == nil ? "absent" : "present") + + " gateConfigured=\(chatFirstMainChatProjectionGate.isConfigured(for: ownerID))") + } let session = try await resolvedAgentClient().resolveSurfaceSession( surface, creationProfile: creationProfile, @@ -4122,6 +4128,15 @@ class ChatProvider: ObservableObject { /// Question-card controls are only live on a completed assistant turn at /// the conversation tail. A later user response retires its choices. + /// Whether the server-owned chat-first capability is currently projected for + /// main chat. A question card renders its options either way; this decides + /// whether they are pressable or dimmed (`QuestionCardView.isCapabilityAvailable`). + func hasChatFirstMainChatCapability() -> Bool { + guard let ownerID = runtimeOwnerId else { return false } + return chatFirstMainChatProjectionGate.capability( + for: mainChatSurfaceReference(), ownerID: ownerID) != nil + } + func isQuestionCardActionable( messageID: String, questionID: String, @@ -6307,25 +6322,42 @@ class ChatProvider: ObservableObject { return normalized } - /// Append text to a streaming message via a buffer that flushes at ~100ms intervals. - /// This reduces SwiftUI re-renders from once-per-token to ~10 times/second. + /// Append text to a streaming message via a buffer that flushes at ~35ms intervals. + /// This reduces SwiftUI re-renders from once-per-token to ~28 times/second, + /// and each of those flushes reveals a paced slice rather than the whole + /// backlog (`ChatStreamingReveal`), so a burst from the wire reads as flow. private func appendToMessage(id: String, text: String) { streamingBuffer.appendText(messageId: id, text: text) { [weak self] in - self?.flushStreamingBuffer() + self?.flushStreamingBuffer(paced: true) } } /// Flush accumulated text and thinking deltas to the published messages array. - private func flushStreamingBuffer() { - streamingBuffer.flush(messages: &messages) { message, text in + /// + /// `paced` is the timer's flush: it lets a bounded slice of text through and + /// re-arms itself while any remains. The un-paced flush is for boundaries — + /// a tool call, the turn settling — where everything must land at once. + private func flushStreamingBuffer(paced: Bool = false) { + let normalize: (ChatMessage, String) -> String = { message, text in if message.sender == .ai { return Self.normalizeStreamingAssistantText(text) } return text } + var remaining = false + if paced { + remaining = streamingBuffer.flushPaced(messages: &messages, normalizeText: normalize) + } else { + streamingBuffer.flush(messages: &messages, normalizeText: normalize) + } for message in messages where message.isStreaming { scheduleJournalUpdate(messageId: message.id, status: .streaming) } + if remaining { + streamingBuffer.scheduleFlush { [weak self] in + self?.flushStreamingBuffer(paced: true) + } + } } /// Add a tool call indicator to a streaming message @@ -6929,6 +6961,10 @@ class ChatProvider: ObservableObject { // the transcript the user just cleared. revokeActiveTurn(reason: .superseded) pendingComposerReferences.removeAll() + // The daily summary renders above the thread as chrome, so the journal + // clear below cannot reach it — and a summary left sitting alone in a chat + // the reader just emptied reads as a clear that did not work. + ChatDailySummaryCoordinator.shared.noteChatCleared() if isInDefaultChat { let runtimeChatId = mainChatRuntimeChatId(sessionId: nil) diff --git a/desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift b/desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift index 819ffdd4568..7f0a089d879 100644 --- a/desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift +++ b/desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift @@ -2639,6 +2639,7 @@ actor RewindDatabase { } Self.registerMemoryLedgerEvidenceMigrations(on: &migrator) + Self.registerFabricatedActionItemTombstoneRepair(on: &migrator) JITTriggerMirrorSchema.registerMigration(on: &migrator) KnowledgeLedgerMirrorStagingSchema.registerMigration(on: &migrator) try migrator.migrate(queue) @@ -2673,6 +2674,46 @@ actor RewindDatabase { } } + /// Clear the local tombstones the Removed lane manufactured over live tasks. + /// + /// `TasksStore.fetchDeletedPage` asked the backend for retired rows with a + /// `deleted=true` query item that `GET /v1/action-items` never had. FastAPI + /// drops an unknown query item, and that handler skips soft-deleted + /// documents outright, so the page it answered with was the user's live + /// tasks — which the lane then stamped retired and synced into this table. + /// Every visit to Removed tombstoned another page. Completing one of those + /// tasks from a chat card read the tombstone back and rendered "Task is no + /// longer available" over a task the reader had just ticked. + /// + /// A genuine retirement always leaves a witness the fabricated ones cannot: + /// a local deletion records `deletedBy`, and a server-side retirement + /// arrives as canonical status `cancelled` or `superseded`. A row carrying + /// neither was retired by nothing but the stamp, so only those are cleared — + /// a real deletion, local or remote, is left exactly as it is. + static func registerFabricatedActionItemTombstoneRepair(on migrator: inout DatabaseMigrator) { + migrator.registerMigration("clearFabricatedActionItemTombstones") { db in + let repaired = + try Int.fetchOne( + db, + sql: """ + SELECT COUNT(*) FROM action_items + WHERE deleted = 1 + AND (deletedBy IS NULL OR deletedBy = '') + AND (taskStatus IS NULL OR taskStatus NOT IN ('cancelled', 'superseded')) + """) ?? 0 + guard repaired > 0 else { return } + try db.execute( + sql: """ + UPDATE action_items + SET deleted = 0 + WHERE deleted = 1 + AND (deletedBy IS NULL OR deletedBy = '') + AND (taskStatus IS NULL OR taskStatus NOT IN ('cancelled', 'superseded')) + """) + log("RewindDatabase: Cleared \(repaired) fabricated action-item tombstone(s)") + } + } + /// A dogfood or QA machine can already carry one of these columns from an earlier build of the /// same branch, where the migration ran under a different identifier. A bare `ADD COLUMN` there /// fails with "duplicate column name" and kills the whole ladder, so probe the table first — diff --git a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift index dc5451648fd..07270cd7716 100644 --- a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift @@ -12,15 +12,6 @@ enum DashboardTaskRefreshPolicy { static let maxServerFetchPages = 3 } -/// Dashboard widgets, SuggestionAssistant grounding, and realtime `getTasks` -/// stay gated on explicit acceptance. The Tasks page reads `incompleteTasks` -/// and shows leftover extractor rows as ordinary due-date tasks. -enum DashboardTaskLanePolicy { - static func admits(_ task: TaskActionItem) -> Bool { - !task.isPendingSuggestion - } -} - enum DashboardExactTaskFetchPolicy { static let maxConcurrentRequests = 6 diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index a6f16c27766..8144c14903f 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -15,7 +15,6 @@ struct ActionItemMetadataBox: @unchecked Sendable { /// Both Dashboard and Tasks tab observe this store /// /// Tasks are loaded separately for incomplete vs completed to minimize memory usage. -/// By default, only recent (7 days) incomplete tasks are loaded. @MainActor class TasksStore: ObservableObject { static let shared = TasksStore() @@ -438,16 +437,29 @@ class TasksStore: ObservableObject { return a.createdAt > b.createdAt } - /// Overdue tasks (due date in the past but within 7 days) — loaded from SQLite + /// Overdue tasks — every incomplete task due before today, loaded from SQLite. + /// Together with `todaysTasks` this is the Tasks page's "Today" category. @Published var overdueTasks: [TaskActionItem] = [] /// Today's tasks (due today) — loaded from SQLite @Published var todaysTasks: [TaskActionItem] = [] - /// Tasks without due date (created within last 7 days) — loaded from SQLite + /// Tasks without a due date — the Tasks page's "No Deadline", loaded from SQLite @Published var tasksWithoutDueDate: [TaskActionItem] = [] - /// Load dashboard task lists directly from SQLite (avoids pagination issues) + /// How many rows a bucket may hold. The spoken answer reads the first 15, but + /// the bucket's *count* is spoken too ("Overdue (82)"), so the cap has to sit + /// well clear of a real backlog or the assistant states a number the Tasks + /// page contradicts — at the old 50 it did. These are small rows, and the + /// Tasks page already materializes every incomplete dated task. + static let dashboardBucketLimit = 500 + + /// Load dashboard task lists directly from SQLite (avoids pagination issues). + /// + /// These three buckets are what the assistant knows about the user's tasks: + /// the voice `get_tasks` tool, the About-user card, and `SuggestionAssistant` + /// grounding all read them. They must partition the same rows the Tasks page + /// shows, or the assistant contradicts the list the user is looking at. func loadDashboardTasks( expectedOwnerID: String? = nil, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil, @@ -462,30 +474,32 @@ class TasksStore: ObservableObject { let calendar = Calendar.current let startOfToday = calendar.startOfDay(for: Date()) let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday)! - let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() do { let snapshot: DashboardTaskSnapshot if let loader { snapshot = try await loader() } else { + // No lower bound. The Tasks page buckets by `dueAt < startOfTomorrow` + // alone (`TasksViewModel.categoryFor`), so a task overdue by more than a + // week is still on the user's list — it was only missing from this one. async let overdueResult = ActionItemStorage.shared.getFilteredActionItems( - limit: 50, + limit: Self.dashboardBucketLimit, completedStates: [false], - dueDateAfter: sevenDaysAgo, dueDateBefore: startOfToday ) async let todayResult = ActionItemStorage.shared.getFilteredActionItems( - limit: 50, + limit: Self.dashboardBucketLimit, completedStates: [false], dueDateAfter: startOfToday, dueDateBefore: endOfToday ) + // Likewise no creation cutoff: "No Deadline" on the Tasks page is every + // undated incomplete task, however long it has been sitting there. async let noDueDateResult = ActionItemStorage.shared.getFilteredActionItems( - limit: 50, + limit: Self.dashboardBucketLimit, completedStates: [false], - dueDateIsNull: true, - createdAfter: sevenDaysAgo + dueDateIsNull: true ) let (overdue, today, noDueDate) = try await ( overdueResult, @@ -499,15 +513,19 @@ class TasksStore: ObservableObject { ) } guard isCurrent(lease) else { return } - // Unreviewed AI captures stay out of dashboard / nudge / realtime lanes. - // The Tasks page uses incompleteTasks and shows those rows as ordinary - // due-date tasks after Candidate review replaced the sparkle list. - let sortedOverdue = snapshot.overdue.filter(DashboardTaskLanePolicy.admits) - .sorted(by: Self.sortByDueDateThenSource) - let sortedToday = snapshot.today.filter(DashboardTaskLanePolicy.admits) - .sorted(by: Self.sortByDueDateThenSource) - let sortedNoDueDate = snapshot.noDueDate.filter(DashboardTaskLanePolicy.admits) - .sorted(by: Self.sortByDueDateThenSource) + // These lanes carry the same rows the Tasks page shows. They used to drop + // AI-capture sources, on the reasoning that a capture is unreviewed until + // the user accepts it — but INV-TASK-2 has since made capture + // suggestion-only (`TaskCaptureModePolicy.usesLegacyStaging` is false for + // every mode), so a capture never reaches `action_items` at all. It stays + // a Candidate until an explicit gesture accepts it. Everything in this + // table is therefore already the user's, and the filter had stopped + // separating reviewed from unreviewed: it only hid the backlog they can + // see on Tasks, plus anything they created by voice, since + // `create_action_item` comes back stamped `conversation`. + let sortedOverdue = snapshot.overdue.sorted(by: Self.sortByDueDateThenSource) + let sortedToday = snapshot.today.sorted(by: Self.sortByDueDateThenSource) + let sortedNoDueDate = snapshot.noDueDate.sorted(by: Self.sortByDueDateThenSource) // Only update @Published properties if values actually changed to avoid unnecessary objectWillChange if overdueTasks != sortedOverdue { overdueTasks = sortedOverdue } if todaysTasks != sortedToday { todaysTasks = sortedToday } @@ -1422,13 +1440,27 @@ class TasksStore: ObservableObject { ) page = .init(items: response.items, hasMore: response.hasMore) } - // The lane is the authority on retirement, not `isRetired`'s re-derivation - // from whatever fields this response happened to carry. Stamping it here — - // after both transports, so neither can skip it — is what stops a retired - // row being written to the local cache as live and resurfacing as a live - // task. Every caller of this page (first load and auto-refresh) syncs it - // into SQLite, so normalizing anywhere later would leave one path wrong. - return .init(items: page.items.map { $0.retired() }, hasMore: page.hasMore) + // Keep only the rows the response itself reports retired, and let the lane + // stamp settle the ones that carry retirement through a field this decode + // did not read (#11460: a retired row written to the cache as live + // resurfaces as a live task). + // + // The lane used to be treated as the authority and stamped `.retired()` + // over the whole page — but `GET /v1/action-items` has no `deleted` + // parameter. FastAPI drops the unknown query item, and the handler's + // stream skips soft-deleted documents outright, so what came back was the + // user's *live* first page. Every caller of this page syncs it into + // SQLite, so each visit to Removed tombstoned a hundred live tasks + // locally: `deleted = 1` with no `deletedBy` and a canonical status still + // `active`. Completing any of them from a chat task card then read the + // tombstone back and rendered "Task is no longer available" over the task + // the reader had just ticked. + // + // Until the backend can scope a page to retired rows, Removed shows the + // deletions made on this Mac (those carry a local tombstone and a + // `deletedBy`) and not another device's. Showing fewer rows is a gap; + // manufacturing retirement is data loss. + return .init(items: page.items.filter(\.isRetired).map { $0.retired() }, hasMore: page.hasMore) } func syncPage( diff --git a/desktop/macos/Desktop/Sources/TrialBannerService.swift b/desktop/macos/Desktop/Sources/TrialBannerService.swift index 2cc2da98d86..8445b74d1e9 100644 --- a/desktop/macos/Desktop/Sources/TrialBannerService.swift +++ b/desktop/macos/Desktop/Sources/TrialBannerService.swift @@ -39,7 +39,8 @@ final class TrialBannerService { title: title, message: message, assistantId: "trial", - sound: .default + sound: .default, + kind: .trial ) } ) { diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 4a13eb521fc..54fed1f2579 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -51,21 +51,6 @@ enum ViewExporter { CGSize(width: 900, height: 600) ), - ( - "02-dashboard", - { - AnyView( - DashboardPage( - viewModel: DashboardViewModel(), - appState: AppState(), - appProvider: AppProvider(), - chatProvider: previewChatProvider(), - memoriesViewModel: MemoriesViewModel(), - selectedIndex: .constant(0))) - }, - CGSize(width: 900, height: 700) - ), - ( "04-conversations", { AnyView(ConversationsPage(appState: AppState(), selectedConversation: .constant(nil))) }, @@ -102,12 +87,6 @@ enum ViewExporter { CGSize(width: 900, height: 600) ), - ( - "13-daily-score", - { AnyView(DailyScoreWidget(dailyScore: nil)) }, - CGSize(width: 400, height: 350) - ), - ( "14-chat-sessions", { AnyView(ChatSessionsSidebar(chatProvider: ChatProvider())) }, @@ -278,19 +257,6 @@ enum ViewExporter { static func fullPageViewAt(_ index: Int) -> (String, AnyView, CGSize)? { // Pages that can be shown with the sidebar let pages: [(String, Int, () -> AnyView)] = [ - ( - "full-dashboard", 0, - { - AnyView( - DashboardPage( - viewModel: DashboardViewModel(), - appState: AppState(), - appProvider: AppProvider(), - chatProvider: ChatProvider(), - memoriesViewModel: previewMemoriesViewModel(), - selectedIndex: .constant(0))) - } - ), ( "full-conversations", 1, { diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index a486d51216f..e7c885491d3 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -8,7 +8,7 @@ class ViewModelContainer: ObservableObject { let tasksStore = TasksStore.shared /// Universal canonical goal projection. It is injected only by the /// capability-gated chat-first shell. - let canonicalGoalsStore = CanonicalGoalsStore() + let canonicalGoalsStore = CanonicalGoalsStore.shared /// Process-launch anchor for startup warmups. Captured at container init /// (≈ app launch) so post-onboarding / late main-content appearance does /// not re-pay launch-protection delays. diff --git a/desktop/macos/Desktop/Tests/ActionItemTombstoneRepairTests.swift b/desktop/macos/Desktop/Tests/ActionItemTombstoneRepairTests.swift new file mode 100644 index 00000000000..d59f09e4365 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ActionItemTombstoneRepairTests.swift @@ -0,0 +1,117 @@ +import Foundation +import GRDB +import XCTest + +@testable import Omi_Computer + +/// The repair for the tombstones the Removed lane manufactured over live tasks. +/// +/// `TasksStore.fetchDeletedPage` asked for retired rows with a `deleted=true` +/// query item that `GET /v1/action-items` never had. FastAPI drops an unknown +/// query item and that handler skips soft-deleted documents outright, so the +/// page it answered with was the owner's live tasks — stamped retired and +/// synced into `action_items` a hundred at a time. Completing one of them from +/// a chat task card read the tombstone back and rendered "Task is no longer +/// available" over the task the reader had just ticked. +/// +/// The migration has to undo exactly those and nothing else, so the two halves +/// of this file are equally load-bearing: a repair that over-reaches +/// resurrects tasks the owner deliberately deleted. +final class ActionItemTombstoneRepairTests: XCTestCase { + private func makeDatabase() throws -> DatabaseQueue { + let queue = try DatabaseQueue() + try queue.write { db in + try db.execute( + sql: """ + CREATE TABLE action_items ( + id INTEGER PRIMARY KEY, + backendId TEXT, + description TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT 0, + deletedBy TEXT, + taskStatus TEXT + ) + """) + } + return queue + } + + private func insert( + _ queue: DatabaseQueue, + id: String, + deleted: Bool, + deletedBy: String?, + taskStatus: String? + ) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO action_items (backendId, description, completed, deleted, deletedBy, taskStatus) + VALUES (?, ?, 0, ?, ?, ?) + """, + arguments: [id, id, deleted, deletedBy, taskStatus]) + } + } + + private func isDeleted(_ queue: DatabaseQueue, id: String) throws -> Bool { + try queue.read { db in + try Bool.fetchOne(db, sql: "SELECT deleted FROM action_items WHERE backendId = ?", arguments: [id]) ?? false + } + } + + private func migrate(_ queue: DatabaseQueue) throws { + var migrator = DatabaseMigrator() + RewindDatabase.registerFabricatedActionItemTombstoneRepair(on: &migrator) + try migrator.migrate(queue) + } + + /// The row the lane fabricated: retired by the stamp alone, with a canonical + /// status that still says the task is the owner's to do. + func testAFabricatedTombstoneIsCleared() throws { + let queue = try makeDatabase() + try insert(queue, id: "live-task", deleted: true, deletedBy: nil, taskStatus: "active") + try migrate(queue) + XCTAssertFalse( + try isDeleted(queue, id: "live-task"), + "a tombstone with no deleter and no retired status was written by nothing but the lane stamp") + } + + /// An empty `deletedBy` is the same absence of a witness as a null one — the + /// column is text and both shapes exist in the field. + func testAnEmptyDeletedByCountsAsNoWitness() throws { + let queue = try makeDatabase() + try insert(queue, id: "live-task", deleted: true, deletedBy: "", taskStatus: nil) + try migrate(queue) + XCTAssertFalse(try isDeleted(queue, id: "live-task")) + } + + /// The half that matters most: a deletion the owner actually performed + /// records who did it, and must survive the repair untouched. + func testAUserDeletionIsLeftAlone() throws { + let queue = try makeDatabase() + try insert(queue, id: "removed-by-me", deleted: true, deletedBy: "user", taskStatus: "active") + try migrate(queue) + XCTAssertTrue( + try isDeleted(queue, id: "removed-by-me"), + "resurrecting a task the owner deleted is the failure this repair must not cause") + } + + /// A server-side retirement carries no `deletedBy` (the backend has no such + /// field) but does carry canonical status, which is witness enough. + func testAServerRetirementIsLeftAlone() throws { + let queue = try makeDatabase() + try insert(queue, id: "cancelled", deleted: true, deletedBy: nil, taskStatus: "cancelled") + try insert(queue, id: "superseded", deleted: true, deletedBy: nil, taskStatus: "superseded") + try migrate(queue) + XCTAssertTrue(try isDeleted(queue, id: "cancelled")) + XCTAssertTrue(try isDeleted(queue, id: "superseded")) + } + + func testALiveRowIsNotDisturbed() throws { + let queue = try makeDatabase() + try insert(queue, id: "ordinary", deleted: false, deletedBy: nil, taskStatus: "active") + try migrate(queue) + XCTAssertFalse(try isDeleted(queue, id: "ordinary")) + } +} diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index 016160defa3..e86b72af04c 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -541,7 +541,10 @@ import XCTest XCTAssertFalse(responseSource.contains("proxy.scrollTo(\"bottom\", anchor: .bottom)")) XCTAssertFalse(viewSource.contains("proxy.scrollTo(\"agentBottom\", anchor: .bottom)")) XCTAssertTrue(scrollSource.contains("struct ChatScrollContainer<Content: View>: View")) - XCTAssertTrue(scrollSource.contains("UserScrollDetector {")) + // The detector is mounted, and mounted with the transcript's own + // programmatic-scroll signal — without it a follow-scroll landing under an + // open press reads as the reader taking the viewport. + XCTAssertTrue(scrollSource.contains("UserScrollDetector(programmaticScroll: programmaticScroll) {")) XCTAssertTrue(scrollSource.contains("onScrollSettledAtBottom")) XCTAssertTrue(scrollSource.contains("scheduleSettledBottomFollow")) XCTAssertTrue(scrollSource.contains("Self.isAtBottom(scrollView)")) @@ -843,7 +846,8 @@ import XCTest ownerID: "owner", title: "Replacement notification", message: "Must remain visible", - assistantId: "test"), + assistantId: "test", + kind: .functional), animated: false) scheduler.fire() diff --git a/desktop/macos/Desktop/Tests/ChatCitationTests.swift b/desktop/macos/Desktop/Tests/ChatCitationTests.swift index 9f53b44d03c..a5627065af5 100644 --- a/desktop/macos/Desktop/Tests/ChatCitationTests.swift +++ b/desktop/macos/Desktop/Tests/ChatCitationTests.swift @@ -849,3 +849,140 @@ final class ChatCitationTests: XCTestCase { XCTAssertEqual(consumed.references.map(\.sourceID), ["conversation-20"]) } } + +/// A rendered component is already the citation of the thing it draws. +final class ChatCitationRenderedEntityTests: XCTestCase { + private let task = ChatCitationReference( + ordinal: 1, kind: .task, sourceID: "task-1", title: "Do YC application") + private let memory = ChatCitationReference( + ordinal: 2, kind: .memory, sourceID: "memory-9", title: "Prefers mornings") + + func testTheSourceRailDropsEntitiesTheTurnAlreadyDraws() { + XCTAssertEqual( + ChatCitationMarkup.appendingSelectedSources( + to: "Here are your tasks.", + selectedReferences: [task], + renderedEntityIDs: ["task-1"]), + "Here are your tasks.", + "a task card opens the same task the marker would, and says what it is") + } + + func testTheSourceRailStillCarriesWhatNothingDraws() { + XCTAssertEqual( + ChatCitationMarkup.appendingSelectedSources( + to: "Here are your tasks.", + selectedReferences: [task, memory], + renderedEntityIDs: ["task-1"]), + "Here are your tasks.\n\nSources: [2]", + "the memory has no component, so it keeps its marker") + } + + func testRenderedEntitiesAreReadFromEveryComponentKind() { + let identifiers = ChatCitationMarkup.renderedEntityIDs(in: [ + .taskCard(id: "b1", taskId: "task-1"), + .goalLink(id: "b2", goalId: "goal-1", summary: "Ship"), + .captureLink(id: "b3", conversationId: "conv-1", momentTimestampMs: nil, summary: "Call"), + .memoryLink(id: "b4", memoryId: "memory-9", summary: "Mornings"), + .text(id: "b5", text: "prose"), + ]) + XCTAssertEqual(identifiers, ["task-1", "goal-1", "conv-1", "memory-9"]) + } +} + +/// A follow-up that cites a number it never retrieved is pointing at the list +/// the reader was shown a turn ago. "Pick one conversation from that day", +/// answered without a tool call, wrote `[1]` for the first conversation of the +/// previous answer — and drew it as plain text beside a title nobody could open. +final class ChatCitationInheritanceTests: XCTestCase { + private func reference(_ ordinal: Int, id: String, title: String) -> ChatCitationReference { + ChatCitationReference(ordinal: ordinal, kind: .conversation, sourceID: id, title: title) + } + + private func answer(_ id: String, text: String, references: [ChatCitationReference] = []) + -> ChatMessage + { + ChatMessage( + id: id, + text: text, + sender: .ai, + contentBlocks: references.map { .citation(id: "citation-\($0.ordinal)", reference: $0) }) + } + + func testAFollowUpWithoutItsOwnSourcesBorrowsTheOrdinalItCites() { + let chess = reference(1, id: "conv-chess", title: "Chess, Minecraft Testing") + let earlier = answer( + "list", text: "You spoke with Paul [5] and about chess [1].", + references: [chess, reference(5, id: "conv-paul", title: "Paul")]) + let followUp = answer("pick", text: "The most interesting one was **Chess.** [1]") + + let inherited = ChatCitationMarkup.inheritedReferences( + citedIn: followUp, resolved: [], earlierTurns: [earlier]) + + XCTAssertEqual(inherited, [chess]) + } + + func testATurnsOwnProvenanceOutranksAnEarlierTurnsSameNumber() { + let stale = reference(1, id: "conv-stale", title: "Last week") + let fresh = reference(1, id: "conv-fresh", title: "Today") + let earlier = answer("list", text: "Earlier [1].", references: [stale, reference(2, id: "conv-two", title: "Two")]) + let current = answer("now", text: "Fresh claim [1], and an older one [2].") + + let inherited = ChatCitationMarkup.inheritedReferences( + citedIn: current, resolved: [fresh], earlierTurns: [earlier]) + + XCTAssertEqual(inherited.map(\.sourceID), ["conv-two"], "only the number this turn cannot resolve is borrowed") + } + + func testTheNearestTurnThatHasTheNumberWins() { + let older = answer("older", text: "[1]", references: [reference(1, id: "conv-older", title: "Older")]) + let newer = answer("newer", text: "[1]", references: [reference(1, id: "conv-newer", title: "Newer")]) + let followUp = answer("pick", text: "That one [1].") + + let inherited = ChatCitationMarkup.inheritedReferences( + citedIn: followUp, resolved: [], earlierTurns: [older, newer]) + + XCTAssertEqual(inherited.map(\.sourceID), ["conv-newer"]) + } + + func testLookbackIsBounded() { + let distant = answer("distant", text: "[1]", references: [reference(1, id: "conv-distant", title: "Distant")]) + let between = answer("between", text: "No sources here.") + let followUp = answer("pick", text: "That one [1].") + + XCTAssertTrue( + ChatCitationMarkup.inheritedReferences( + citedIn: followUp, resolved: [], earlierTurns: [distant, between], lookback: 1 + ).isEmpty) + XCTAssertEqual( + ChatCitationMarkup.inheritedReferences( + citedIn: followUp, resolved: [], earlierTurns: [distant, between], lookback: 2 + ).map(\.sourceID), + ["conv-distant"]) + } + + func testUserTurnsAndTheMessageItselfAreNeverASource() { + let user = ChatMessage( + id: "user", text: "[1]", sender: .user, + contentBlocks: [.citation(id: "citation-1", reference: reference(1, id: "conv-user", title: "User"))]) + let followUp = answer("pick", text: "That one [1].", references: []) + + XCTAssertTrue( + ChatCitationMarkup.inheritedReferences( + citedIn: followUp, resolved: [], earlierTurns: [user, followUp] + ).isEmpty) + } + + @MainActor + func testProjectionBindsTheBorrowedReferenceSoTheMarkerOpens() { + let chess = reference(1, id: "conv-chess", title: "Chess, Minecraft Testing") + var messages = [ + answer("list", text: "About chess [1].", references: [chess]), + answer("pick", text: "The most interesting one was **Chess.** [1]"), + ] + + ChatProvider.inheritCitationsAcrossTurns(&messages) + + XCTAssertEqual(messages[1].inlineCitationReferences, [chess]) + XCTAssertEqual(messages[0].inlineCitationReferences, [chess], "the source turn is untouched") + } +} diff --git a/desktop/macos/Desktop/Tests/ChatDailySummaryTests.swift b/desktop/macos/Desktop/Tests/ChatDailySummaryTests.swift index e2e59601e83..408c85e4ba6 100644 --- a/desktop/macos/Desktop/Tests/ChatDailySummaryTests.swift +++ b/desktop/macos/Desktop/Tests/ChatDailySummaryTests.swift @@ -158,6 +158,64 @@ final class ChatDailySummaryTests: XCTestCase { return defaults } + /// Clearing Chat has to take the card with it. + /// + /// The card is chrome above the thread, not a turn (INV-CHAT-1 keeps + /// transcript authorship in the kernel), so the journal clear cannot reach + /// it — and the day's summary was left sitting alone in a chat the reader had + /// just emptied, which reads as a clear that did not work. + @MainActor + func testClearingChatWithdrawsTheCard() async throws { + let box = Box() + box.records = [record(id: "ds_1")] + let coordinator = makeCoordinator(box, defaults: try makeDefaults()) + await coordinator.refresh() + XCTAssertFalse(coordinator.isClearedFromTranscript) + + coordinator.noteChatCleared() + XCTAssertTrue(coordinator.isClearedFromTranscript, "the card must leave with the thread") + + // Still cleared after the next read: the same summary does not come back on + // a refresh, or the card would reappear over an empty chat minutes later. + box.clock = box.clock.addingTimeInterval(3_600) + await coordinator.refresh() + XCTAssertTrue(coordinator.isClearedFromTranscript) + } + + /// Clearing suppresses one summary, not the feature. Tomorrow's comes back. + @MainActor + func testANewerSummaryReturnsAfterAClear() async throws { + let box = Box() + box.records = [record(id: "ds_1")] + let coordinator = makeCoordinator(box, defaults: try makeDefaults()) + await coordinator.refresh() + coordinator.noteChatCleared() + XCTAssertTrue(coordinator.isClearedFromTranscript) + + box.records = [record(id: "ds_2", date: "2026-09-02")] + box.clock = box.clock.addingTimeInterval(3_600) + await coordinator.refresh() + XCTAssertFalse( + coordinator.isClearedFromTranscript, + "a clear withdraws the summary that was on screen, not every summary after it") + } + + /// The watermark is per account, like the announcement's. Clearing on one + /// account must not blank the next reader's day on a shared Mac. + @MainActor + func testAClearOnOneAccountDoesNotWithdrawAnothersSummary() async throws { + let box = Box() + box.records = [record(id: "ds_1")] + let coordinator = makeCoordinator(box, defaults: try makeDefaults()) + await coordinator.refresh() + coordinator.noteChatCleared() + + box.owner = "owner-b" + box.clock = box.clock.addingTimeInterval(3_600) + await coordinator.refresh() + XCTAssertFalse(coordinator.isClearedFromTranscript) + } + @MainActor func testNoSummaryLeavesNothingToRenderAndAnnouncesNothing() async throws { let box = Box() @@ -277,4 +335,66 @@ final class ChatDailySummaryTests: XCTestCase { XCTAssertEqual(question, "What did I do on Sun, Aug 23?") } + // MARK: - Transcript admission (INV-CHAT-2) + + /// The reported launch ergonomics: the card admitted above a transcript that + /// was still loading printed the summary alone over a spinner, and the reader + /// watched it yank above the fold when history landed at the live edge. + func testAdmissionDefersToTheInitialHistoryLoad() { + XCTAssertFalse( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: false, alreadyAdmitted: false, + isLoadingInitial: true, scrollMode: .followingBottom, hasMessages: false), + "Launch must not print the summary above a transcript that is still loading" + ) + XCTAssertTrue( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: false, alreadyAdmitted: false, + isLoadingInitial: false, scrollMode: .followingBottom, hasMessages: true), + "The loading-complete observer admits once the snapshot is placed" + ) + XCTAssertTrue( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: false, alreadyAdmitted: false, + isLoadingInitial: false, scrollMode: .followingBottom, hasMessages: false), + "A genuinely empty thread shows the card once loading completes" + ) + } + + func testAdmissionNeverMovesAReaderWhoScrolledAway() { + XCTAssertFalse( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: false, alreadyAdmitted: false, + isLoadingInitial: false, scrollMode: .freeScrolling, hasMessages: true), + "A reader away from the live edge meets the card on their next return to the bottom" + ) + XCTAssertTrue( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: false, alreadyAdmitted: false, + isLoadingInitial: false, scrollMode: .freeScrolling, hasMessages: false), + "An empty thread has nothing to move" + ) + XCTAssertFalse( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: false, alreadyAdmitted: true, + isLoadingInitial: false, scrollMode: .followingBottom, hasMessages: true), + "Once admitted the card stays" + ) + } + + func testAdmissionWithdrawsWhenThereIsNothingToAdmit() { + XCTAssertFalse( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: false, isClearedFromTranscript: false, alreadyAdmitted: true, + isLoadingInitial: false, scrollMode: .followingBottom, hasMessages: true), + "A summary that disappeared (owner change) withdraws the card" + ) + XCTAssertFalse( + ChatDailySummaryAdmission.shouldAdmit( + hasSummary: true, isClearedFromTranscript: true, alreadyAdmitted: true, + isLoadingInitial: false, scrollMode: .followingBottom, hasMessages: true), + "A cleared summary keeps the card away" + ) + } + } diff --git a/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift b/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift index 8110561345a..d44f66fbb6e 100644 --- a/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift +++ b/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift @@ -198,7 +198,10 @@ final class ChatDiscoverabilityTests: XCTestCase { declaredTools.insert(name) } } - let localApiOnlyTools: Set<String> = ["get_local_status", "get_screenshot"] + // `look_at_frame` is `get_screenshot`'s alias on the local agent API, and + // inherits its reach: the local API is the only adapter either is + // advertised to, so neither can appear in a chat adapter's declarations. + let localApiOnlyTools: Set<String> = ["get_local_status", "get_screenshot", "look_at_frame"] for toolName in DesktopCapabilityRegistry.desktopToolNames where !localApiOnlyTools.contains(toolName) { XCTAssertTrue(declaredTools.contains(toolName), "Missing agent tool declaration for \(toolName)") diff --git a/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift b/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift index f76d7b5a657..b4872f7afac 100644 --- a/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift +++ b/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift @@ -336,32 +336,27 @@ final class ChatErrorStateTests: XCTestCase { XCTAssertTrue(snippet.contains("await sendMessage(prompt)")) } - func testDashboardShowsChatErrorCard() throws { - let source = try sourceFile("MainWindow/Pages/DashboardPage.swift") - XCTAssertTrue(source.contains("dashboardChatErrorCard")) + func testHomeShowsChatErrorCard() throws { + let source = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") XCTAssertTrue(source.contains("ChatErrorCard(")) } - /// Static tripwire for the Home chat layout. The shared ChatErrorCard belongs to - /// homePanelStage, below the composer; placing it inside homeChatPanel as well - /// visibly duplicates the sign-in recovery CTA for the same ChatProvider state. - func testDashboardHomeChatHasOneSharedErrorCardRenderSite() throws { - let source = try sourceFile("MainWindow/Pages/DashboardPage.swift") - let panelStart = try XCTUnwrap(source.range(of: "private func homePanelStage")) - let chatStart = try XCTUnwrap(source.range(of: "private func homeChatPanel")) - let connectStart = try XCTUnwrap(source.range(of: "private func homeConnectPanel")) - - let panelSource = String(source[panelStart.lowerBound..<chatStart.lowerBound]) - let chatSource = String(source[chatStart.lowerBound..<connectStart.lowerBound]) - + /// Static tripwire for the Home chat layout. The shared `ChatErrorCard` has one + /// render site: the answer thread, below the transcript. `DashboardPage` used to + /// carry a second copy inside its embedded chat panel, which duplicated the + /// sign-in recovery CTA for the same `ChatProvider` state; the page is gone and + /// this pins the surviving surface to exactly one site. + func testHomeChatHasOneSharedErrorCardRenderSite() throws { + let source = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") XCTAssertEqual( - panelSource.components(separatedBy: "dashboardChatErrorCard").count - 1, + source.components(separatedBy: "ChatErrorCard(").count - 1, 1, - "Home must have one canonical error-card owner outside the chat panel." + "Home must have exactly one error-card render site." ) + let home = try sourceFile("MainWindow/QueryShell/QueryShellHome.swift") XCTAssertFalse( - chatSource.contains("dashboardChatErrorCard"), - "The embedded chat panel must not render a second copy of the shared auth gate." + home.contains("ChatErrorCard("), + "The host must not render a second copy of the shared auth gate above the thread." ) } diff --git a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift index c66a09ba2e1..5aacc98891c 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -3,7 +3,12 @@ import XCTest @testable import Omi_Computer final class ChatFirstRichBlockTests: XCTestCase { - private func conversation(id: String) -> ServerConversation { + private func conversation( + id: String, + source: ConversationSource = .desktop, + status: ConversationStatus = .completed, + discarded: Bool = false + ) -> ServerConversation { ServerConversation( id: id, createdAt: Date(timeIntervalSince1970: 1_000), @@ -23,10 +28,10 @@ final class ChatFirstRichBlockTests: XCTestCase { geolocation: nil, photos: [], appsResults: [], - source: .desktop, + source: source, language: "en", - status: .completed, - discarded: false, + status: status, + discarded: discarded, deleted: false, isLocked: false, starred: false, @@ -55,6 +60,77 @@ final class ChatFirstRichBlockTests: XCTestCase { ) } + /// The reported failure: an agent's conversation search returns desktop + /// recordings, and a citation naming one used to route the capture focus, + /// whose source-scoped archive fetch rejected it — landing the reader on the + /// Conversations list with nothing opened. + func testCitationRouteOpensNonCaptureConversationsAsExactRecords() { + let desktop = conversation(id: "desktop-1", source: .desktop) + XCTAssertEqual( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: desktop, + requestedID: "desktop-1", + momentTimestampMs: nil), + .exactRecord, + "A desktop recording must open as the exact fetched record, not the capture focus" + ) + + let phone = conversation(id: "phone-1", source: .phone) + XCTAssertEqual( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: phone, + requestedID: "phone-1", + momentTimestampMs: nil), + .exactRecord + ) + + let discardedCapture = conversation(id: "omi-d", source: .omi, discarded: true) + XCTAssertEqual( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: discardedCapture, + requestedID: "omi-d", + momentTimestampMs: nil), + .exactRecord, + "A discarded capture is outside the archive contract but still an openable record" + ) + } + + func testCitationRouteKeepsCaptureFocusAndMomentForOmiCaptures() { + let capture = conversation(id: "omi-1", source: .omi) + XCTAssertEqual( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: capture, + requestedID: "omi-1", + momentTimestampMs: 16_000), + .captureFocus(momentTs: 16.0), + "An Omi-device capture keeps the capture focus so its moment still plays" + ) + XCTAssertEqual( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: capture, + requestedID: "omi-1", + momentTimestampMs: nil), + .captureFocus(momentTs: nil) + ) + } + + func testCitationRouteRefusesToNavigateWhenTheRecordCannotBeTrusted() { + XCTAssertNil( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: nil, + requestedID: "gone-1", + momentTimestampMs: nil), + "A failed fetch must not navigate anywhere instead of stranding the reader on a list" + ) + XCTAssertNil( + ChatFirstConversationLinkPolicy.citationRoute( + forFetched: conversation(id: "other-1"), + requestedID: "gone-1", + momentTimestampMs: nil), + "A mismatched fetch must not open a nearby row" + ) + } + func testBlockWireRejectsTheEntireToolPayloadWhenAnyBlockIsMalformed() { let converted = ChatFirstBlockWire.backendBlocks( from: [ @@ -255,7 +331,11 @@ final class ChatFirstRichBlockTests: XCTestCase { XCTAssertEqual(summary, "After") } - func testRichRendererSelectionRequiresExplicitChatFirstContext() { + /// Every Chat surface renders every rich block. This used to assert the + /// opposite — that a caller without an explicit context got nothing — which is + /// how a turn whose only content was a task card read as an empty assistant + /// reply in the task panel and in the notch. + func testEveryRichBlockSurvivesGroupingOnEveryChatSurface() { let blocks: [ChatContentBlock] = [ .questionCard( id: "question", questionId: "question-1", text: "Question", subjectKind: "goal", subjectId: "goal-1", @@ -264,45 +344,15 @@ final class ChatFirstRichBlockTests: XCTestCase { .taskCard(id: "task", taskId: "task-1"), .goalLink(id: "goal", goalId: "goal-1", summary: "Goal"), .captureLink(id: "capture", conversationId: "capture-1", momentTimestampMs: nil, summary: "Capture"), + .conversationLink( + id: "conversation", conversationId: "conversation-1", summary: "Conversation", + recommendedActionItems: []), .memoryLink(id: "memory", memoryId: "memory-1", summary: "Memory"), ] - XCTAssertTrue( - ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false).isEmpty, - "legacy, floating, task, and onboarding call sites must keep rich blocks inert" - ) - - let enabled = ContentBlockGroup.visibleChatGroups( - blocks, - isStreaming: false, - richBlockRenderingEnabled: true - ) - XCTAssertEqual(enabled.count, 5) - XCTAssertTrue( - enabled.contains { - if case .questionCard = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .taskCard = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .goalLink = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .captureLink = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .memoryLink = $0 { return true } - return false - }) + let groups = ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false) + XCTAssertEqual(groups.count, 6) + XCTAssertEqual(groups.map(\.id), blocks.map(\.id), "order is the transcript's, not the renderer's") } func testTaskAcknowledgementRequiresReconciledCompletedRecord() { diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift index aaf7116befc..dbd6050d0db 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -47,23 +47,23 @@ final class ChatFirstShellTests: XCTestCase { ) } - func testSuccessfulSampleSelectsChatFirstAndCannotLiveSwap() throws { - var sample = ChatFirstShellCapabilitySample() + func testSuccessfulSampleResolvesCapabilityAndCannotLiveSwap() throws { + var sample = ChatFirstCapabilitySample() sample.resolve( control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: true ) - XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) - XCTAssertEqual(sample.variant.stableName, "chat_first") + XCTAssertEqual(sample.projection?.controlGeneration, 7) + XCTAssertTrue(sample.isResolved) sample.resolve( control: OmiAPI.TaskWorkflowControl(accountGeneration: 8, chatFirstUi: false, workflowMode: .off), requestedOwnerID: "owner-a", ownerIsStillCurrent: true ) - XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) + XCTAssertEqual(sample.projection?.controlGeneration, 7) } func testLegacyWorkflowMetadataCannotSuppressDerivedChatFirstCapability() throws { @@ -79,38 +79,20 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(projection.controlGeneration, 9) } - func testOnlyLegacyShellUsesThePostOnboardingFloatingPopup() { - var enabled = ChatFirstShellCapabilitySample() - enabled.resolve( - control: enabledControl(), - requestedOwnerID: "owner-a", - ownerIsStillCurrent: true - ) - - XCTAssertFalse( - DesktopShellPresentationPolicy.usesLegacyPostOnboardingPopup(false, enabled.variant), - "chat-first starter prompts belong to the main chat") - XCTAssertTrue( - DesktopShellPresentationPolicy.usesLegacyPostOnboardingPopup(false, .legacy), - "the server-selected legacy shell retains its floating prompt") - XCTAssertTrue( - DesktopShellPresentationPolicy.usesLegacyPostOnboardingPopup(true, enabled.variant), - "the explicit legacy preference remains authoritative") - } - - func testMissingStaleAndOwnerChangedSamplesFailClosed() { - var missing = ChatFirstShellCapabilitySample() + func testMissingStaleAndOwnerChangedSamplesFailClosedToCapabilityOff() { + var missing = ChatFirstCapabilitySample() missing.resolve(control: nil, requestedOwnerID: "owner-a", ownerIsStillCurrent: true) - XCTAssertEqual(missing.variant.stableName, "legacy") + XCTAssertNil(missing.projection) + XCTAssertTrue(missing.isResolved, "a failed read still resolves — it must not re-request forever") - var stale = ChatFirstShellCapabilitySample() + var stale = ChatFirstCapabilitySample() stale.resolve(control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: false) - XCTAssertEqual(stale.variant.stableName, "legacy") + XCTAssertNil(stale.projection) - var ownerChanged = ChatFirstShellCapabilitySample() + var ownerChanged = ChatFirstCapabilitySample() ownerChanged.resolve(control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: true) ownerChanged.ownerDidChange(to: "owner-b") - XCTAssertEqual(ownerChanged.variant.stableName, "legacy") + XCTAssertNil(ownerChanged.projection) } func testNavigationPersistsOnlyRouteAndCollapseAndRetainsFocusUntilAcknowledged() throws { @@ -493,32 +475,31 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "settings"), .more(.settings)) XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "home"), .chat) XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "dashboard"), .chat) + // `navigate help` resolved a title no shell mounted and then timed out. + XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "help"), .more(.settings)) + XCTAssertTrue(ChatFirstRoute.isHelpAutomationTarget("HELP")) + XCTAssertFalse(ChatFirstRoute.isHelpAutomationTarget("settings")) XCTAssertTrue( DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: "chat_first", - selectedTab: nil, + shellVariant: DesktopAutomationSnapshot.singleShellVariant, visibleChatFirstRoute: "tasks", - expectedChatFirstRoute: "tasks", - expectedLegacyTitle: "Tasks" + expectedChatFirstRoute: "tasks" ) ) - XCTAssertTrue( + XCTAssertFalse( DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: "legacy", - selectedTab: "Tasks", - visibleChatFirstRoute: nil, - expectedChatFirstRoute: "tasks", - expectedLegacyTitle: "Tasks" + shellVariant: DesktopAutomationSnapshot.singleShellVariant, + visibleChatFirstRoute: "chat", + expectedChatFirstRoute: "tasks" ) ) + // No shell has reported state yet: a target cannot be "visible" on nothing. XCTAssertFalse( DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: "loading", - selectedTab: "Tasks", - visibleChatFirstRoute: nil, - expectedChatFirstRoute: "tasks", - expectedLegacyTitle: "Tasks" + shellVariant: nil, + visibleChatFirstRoute: "tasks", + expectedChatFirstRoute: "tasks" ) ) } @@ -569,69 +550,6 @@ final class ChatFirstShellTests: XCTestCase { ) } - func testExplicitLegacyDesignIsTheOnlyPathThatMountsTheSidebarShell() throws { - var sample = ChatFirstShellCapabilitySample() - sample.resolve( - control: enabledControl(), - requestedOwnerID: "owner-a", - ownerIsStillCurrent: true - ) - - XCTAssertTrue( - DesktopShellPresentationPolicy.usesChatFirst(false, sample.variant) - ) - XCTAssertFalse( - DesktopShellPresentationPolicy.usesChatFirst(true, sample.variant) - ) - XCTAssertFalse( - DesktopShellPresentationPolicy.usesChatFirst(false, .legacy) - ) - } - - /// **The legacy shell has no Home stage, and must not claim one.** Its Home is the query surface; - /// the only branch there that still mounts `DashboardPage` needs `useLegacyHomeDesign`, which - /// renders `legacyHome`. So no value of any input can make the legacy shell report a stage mode. - /// - /// The bug this replaces reported `hub` for exactly this shell, forever, because the guard was - /// written when the non-legacy legacy-shell Home *was* `DashboardPage`. It never read as broken: - /// `hub` is a legitimate mode, so `/state` looked healthy while describing a surface that was not - /// mounted, and a flow waiting for `chat` waited for a transition nothing could produce. - func testTheLegacyShellReportsNoHomeStageModeWhateverItWasLastTold() { - for route in [ChatFirstRoute.chat, .more(.dashboard), .tasks] { - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: false, - chatFirstRoute: route, - lastPublishedMode: "hub"), - "the legacy shell renders no stage, so it may not report one even with a route in hand") - } - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: false, - chatFirstRoute: nil, - lastPublishedMode: "connect")) - } - - /// On the shell that *does* mount `DashboardPage`, the field carries what that page published — - /// unchanged, and `nil` until it has published anything. The shell is a courier here, not a source: - /// substituting a default is what turned a missing reading into a false one. - func testTheChatFirstShellCarriesTheStageOwnersValueWithoutInventingOne() { - for mode in ["hub", "chat", "connect"] { - XCTAssertEqual( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: true, - chatFirstRoute: .chat, - lastPublishedMode: mode), - mode) - } - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: true, - chatFirstRoute: .chat, - lastPublishedMode: nil), - "before DashboardPage reports, the honest answer is 'not known', not 'hub'") - } - func testChatFirstGlassBoundaryWrapsOnlyRoutesWithoutTheirOwnPanels() { let wrapped: [ChatFirstRoute] = [ .goals, @@ -733,28 +651,6 @@ final class ChatFirstShellTests: XCTestCase { } } - /// Only the two routes that mount `DashboardPage` have a stage. Navigating away publishes `nil` - /// rather than leaving the last mode standing, which is how the field stops describing a page that - /// is no longer on screen. - func testOnlyTheRoutesThatMountDashboardPageReportAStage() { - XCTAssertTrue(HomeStageAutomationPolicy.mountsHomeStage(.chat)) - XCTAssertTrue(HomeStageAutomationPolicy.mountsHomeStage(.more(.dashboard))) - - for route: ChatFirstRoute in [ - .conversations, .tasks, .goals, .memories, - .more(.apps), .more(.rewind), .more(.settings), .more(.permissions), - ] { - XCTAssertFalse( - HomeStageAutomationPolicy.mountsHomeStage(route), - "\(route.stableName) does not render the stage") - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: true, - chatFirstRoute: route, - lastPublishedMode: "connect"), - "\(route.stableName) must not keep reporting the mode the stage had before we left it") - } - } } private final class ChatFirstGlassFrameRecorder: @unchecked Sendable { diff --git a/desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift b/desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift new file mode 100644 index 00000000000..baa41377a3c --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift @@ -0,0 +1,282 @@ +import XCTest + +@testable import Omi_Computer + +/// Ticking a task card in the transcript. +/// +/// The card has no list to fall back on: it names one task by id and draws +/// whatever the store says that task is. Completion is the one gesture that +/// moves a task between the store's two arrays, so it is also the one gesture +/// that can lose it — and a card that loses its task does not show a ticked +/// box, it shows "Task is no longer available", which reads as if the task had +/// been deleted rather than done. +@MainActor +final class ChatFirstTaskCardCompletionTests: XCTestCase { + private var fixture: RewindStorageTestIsolation.Fixture? + private var previousOwnerID: String? + private var previousAuth: RewindStorageTestIsolation.AuthSnapshot? + + override func setUp() async throws { + let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "task-card-completion") + self.fixture = fixture + previousAuth = RewindStorageTestIsolation.captureAuthSnapshot() + previousOwnerID = RuntimeOwnerIdentity.currentOwnerId() + await transitionOwner(to: fixture.testUserId) + RewindStorageTestIsolation.signInForTests(userId: fixture.testUserId) + TasksStore.shared.resetSessionState() + } + + override func tearDown() async throws { + TasksStore.shared.resetSessionState() + if let previousAuth { RewindStorageTestIsolation.restoreAuthSnapshot(previousAuth) } + await transitionOwner(to: previousOwnerID) + await RewindStorageTestIsolation.tearDown(userDir: fixture?.userDir) + fixture = nil + } + + /// The card reads `TasksStore.tasks`. Completing has to leave the task + /// somewhere in there, still not retired, or the card has nothing to draw. + func testACompletedTaskIsStillTheTaskTheCardNames() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Attend the Claw hackathon", + completed: false, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + + let hydrated = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap(hydrated, "the card hydrates its task before it can draw one") + XCTAssertFalse(task.completed) + + await toggleWithRemoteAccepting(task, store: store) + + let afterToggle = store.tasks.first { $0.id == "card-task" } + let stillThere = try XCTUnwrap( + afterToggle, "completing a task must not take it out of the store the card reads") + XCTAssertTrue(stillThere.completed, "the box is ticked, not emptied") + XCTAssertFalse(stillThere.isRetired, "completing is not retiring") + } + + /// What the card actually renders, through its own presentation rule. + func testTheCardShowsTheTickedTaskRatherThanAnUnavailablePlaceholder() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Implement one-click summary email", + completed: false, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + + let resolved = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap(resolved) + await toggleWithRemoteAccepting(task, store: store) + + let liveTask = store.tasks.first { $0.id == "card-task" && !$0.isRetired } + let displayed = ChatFirstTaskCardPresentation.displayTask( + liveTask: liveTask, + retainedCompletedTask: nil + ) + let shown = try XCTUnwrap( + displayed, + "a task the reader just ticked is done, not gone — the card must not fall through to " + + "\"Task is no longer available\"") + XCTAssertTrue(shown.completed) + } + + /// The card re-hydrates by id whenever it loses the row — after a relaunch, + /// or when the store's arrays are rebuilt under it. A completed task has to + /// come back from that lookup too. + func testAFreshCardStillResolvesATaskThatWasAlreadyCompleted() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Get the agent ecosystem working again", + completed: true, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + store.resetSessionState() + + let resolved = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap( + resolved, "a completed task is still a task the card can name and draw") + XCTAssertTrue(task.completed) + XCTAssertFalse(task.isRetired) + } + + /// The toggle with its network legs stubbed to succeed — the production case, + /// where the backend accepts the completion. A failed remote leg is a + /// different behaviour (rollback) with its own coverage. + private func toggleWithRemoteAccepting( + _ task: TaskActionItem, + store: TasksStore + ) async { + await store.toggleTask( + task, + operationOverrides: TasksStore.ToggleOperationOverrides( + updateLocal: { completed, _ in + try await ActionItemStorage.shared.updateCompletionStatus( + backendId: task.id, completed: completed, authorization: .unrestricted) + guard + let stored = try await ActionItemStorage.shared.getLocalActionItem( + byBackendId: task.id) + else { throw CocoaError(.fileNoSuchFile) } + return stored + }, + refreshDashboard: { _ in await store.loadDashboardTasks() }, + updateRemote: { _, _ in + guard + let stored = try await ActionItemStorage.shared.getLocalActionItem( + byBackendId: task.id) + else { throw CocoaError(.fileNoSuchFile) } + return stored + }, + syncRemote: { _, _ in }, + rollbackLocal: {} + )) + } + + /// The card's own state machine, driven through the interleaving that made a + /// ticked task read as a deleted one. + /// + /// Ticking moves the task between the store's two arrays, and the toggle + /// awaits SQLite before it does — so `liveTask` can be nil for a moment. The + /// card's `hydrationKey` flips on exactly that, SwiftUI cancels the in-flight + /// hydration, and `TasksStore.isCurrent` folds `!Task.isCancelled` into its + /// lease check, so `resolveCanonicalTask` answers nil by construction. The + /// old code published that nil. + func testACancelledHydrationDoesNotSpeakForTheCard() { + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: true, hasLiveTask: false), + .abandon, + "a hydration SwiftUI has already superseded must not write the card's state") + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: true, hasLiveTask: true), + .abandon) + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: false, hasLiveTask: true), + .settle, + "the store already has the task — there is nothing left to hydrate") + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: false, hasLiveTask: false), + .adopt, + "an uncontested hydration is the card's answer") + } + + /// The whole point, stated as the reader sees it: a task ticked and then + /// abandoned by a late nil is still shown, ticked. + func testATickedTaskSurvivesALateNilFromASupersededHydration() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Attend the Claw hackathon", + completed: false, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + let resolved = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap(resolved) + await toggleWithRemoteAccepting(task, store: store) + + let ticked = try XCTUnwrap(store.tasks.first { $0.id == "card-task" }) + XCTAssertTrue(ticked.completed) + + // The card has retained the ticked row. A hydration that started before the + // tick now returns nil, cancelled. + var retained: TaskActionItem? = ticked + switch ChatFirstTaskCardHydration.resolution(isCancelled: true, hasLiveTask: false) { + case .abandon: + break + case .settle, .adopt: + retained = nil // what the old code did with a nil answer + } + + XCTAssertNotNil( + ChatFirstTaskCardPresentation.displayTask(liveTask: nil, retainedCompletedTask: retained), + "the reader ticked this task — the card owes them a ticked box, not " + + "\"Task is no longer available\"") + } + + /// The reader's own tick outranks a store row that reads retired. + /// + /// This is the field failure: the Removed lane tombstoned live tasks in the + /// local cache, so completing one of them read the row back retired and the + /// card swapped the reader's ticked box for "Task is no longer available". + /// The lane no longer fabricates those tombstones and a migration clears the + /// ones it left, but a completion the app accepted must never be erasable by + /// a later read — whatever put the retirement there. + func testAReaderCompletionSurvivesARowThatReadsRetired() throws { + let tombstoned = TaskActionItem( + id: "card-task", + description: "Fix Omi tasks being too noisy", + completed: true, + createdAt: Date(), + source: "manual" + ).retired() + XCTAssertTrue(tombstoned.isRetired, "fixture must carry the stale tombstone that caused this") + + XCTAssertNil( + ChatFirstTaskCardPresentation.displayTask( + liveTask: tombstoned, + retainedCompletedTask: tombstoned), + "without the reader's own completion, a retired row is still grounds for taking the card away") + + let shown = try XCTUnwrap( + ChatFirstTaskCardPresentation.displayTask( + liveTask: tombstoned, + retainedCompletedTask: nil, + locallyCompletedTask: tombstoned), + "the reader ticked this card — a retirement found afterwards does not get to undo that") + XCTAssertTrue(shown.completed) + } + + /// Unticking is the one gesture that clears it: the card follows the reader, + /// not a completion it has decided to keep forever. + func testUntickingDropsTheRetainedReaderCompletion() { + XCTAssertNil( + ChatFirstTaskCardPresentation.displayTask( + liveTask: nil, + retainedCompletedTask: nil, + locallyCompletedTask: nil), + "the toggle clears the local completion when the reader unticks, leaving the store to answer") + } + + private func transitionOwner(to ownerID: String?) async { + do { + _ = try await RuntimeOwnerIdentity.performEffectiveOwnerTransition( + plannedNextOwner: { _, _ in ownerID }, + quiesceVoice: { _, _ in }, + retargetLocalStorage: { _, _ in }, + ownerDidChange: {}, + { defaults in + defaults.removeObject(forKey: .automationOwnerOverride) + if let ownerID { + defaults.set(ownerID, forKey: .authUserId) + } else { + defaults.removeObject(forKey: .authUserId) + } + }) + } catch { + XCTFail("owner transition failed: \(error)") + } + } +} diff --git a/desktop/macos/Desktop/Tests/ChatProseRenderCacheTests.swift b/desktop/macos/Desktop/Tests/ChatProseRenderCacheTests.swift new file mode 100644 index 00000000000..0208fd903cf --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatProseRenderCacheTests.swift @@ -0,0 +1,201 @@ +import AppKit +import XCTest + +@testable import Omi_Computer + +/// The prose memo's contract: a hit must be indistinguishable from a +/// recompute, keys must be sensitive to every input that changes the output, +/// and the two maps that grow (entries, per-entry heights) must stay bounded. +@MainActor +final class ChatProseRenderCacheTests: XCTestCase { + + /// XCTest's `setUp` is nonisolated and cannot reach the MainActor cache + /// directly, so every test begins by emptying it here. + private func beginFresh() { + ChatProseRenderCache.removeAll() + } + + // MARK: - Hit identity + + func testTheSameKeyReturnsTheSameEntryWithoutReproducing() { + beginFresh() + var productions = 0 + let key = Self.key(markdown: "Hello **world**") + let first = ChatProseRenderCache.entry(for: key) { + productions += 1 + return NSAttributedString(string: "Hello world") + } + let second = ChatProseRenderCache.entry(for: key) { + productions += 1 + return NSAttributedString(string: "Hello world") + } + + XCTAssertNotNil(first) + XCTAssertTrue(first === second, "a hit must return the cached entry itself") + XCTAssertEqual(productions, 1, "a hit must not run produce again") + } + + func testEveryKeyInputThatChangesTheOutputProducesAMiss() { + beginFresh() + let inputs: [(name: String, key: ChatProseRenderCache.Key)] = [ + ("text", Self.key(markdown: "one")), + ("markdown", Self.key(markdown: "one **two**")), + ("style", Self.key(markdown: "same", style: .user)), + ("fontSize", Self.key(markdown: "same", fontSize: 17)), + ("fontScaleMilli", Self.key(markdown: "same", fontScaleMilli: 1_100)), + ("citationOrdinals", Self.key(markdown: "same", citationOrdinals: [3])), + ("citationOrder", Self.key(markdown: "same", citationOrdinals: [3, 1])), + ] + + for (index, input) in inputs.enumerated() { + let base = inputs[max(0, index - 1)].key + // Distinct pairs only: two neighbours can name the same input if the + // list above ever repeats one. + guard base != input.key else { continue } + var producedForChangedInput = false + // The base lookup may hit or miss depending on earlier iterations; only + // the changed key matters — it must never hit. + _ = ChatProseRenderCache.entry(for: base) { NSAttributedString(string: "x") } + let second = ChatProseRenderCache.entry(for: input.key) { + producedForChangedInput = true + return NSAttributedString(string: "x") + } + XCTAssertTrue(producedForChangedInput, "changing \(input.name) must re-run produce") + XCTAssertNotNil(second) + } + } + + // MARK: - Unproducible blocks + + func testNilFromProduceIsNeverCached() { + beginFresh() + var productions = 0 + let key = Self.key(markdown: "| a table |") + let first = ChatProseRenderCache.entry(for: key) { + productions += 1 + return nil + } + let second = ChatProseRenderCache.entry(for: key) { + productions += 1 + return nil + } + + XCTAssertNil(first) + XCTAssertNil(second) + XCTAssertEqual(productions, 2, "a nil produce must be tried again, not remembered") + + let third = ChatProseRenderCache.entry(for: key) { + productions += 1 + return NSAttributedString(string: "now it renders") + } + XCTAssertNotNil(third) + XCTAssertEqual(productions, 3) + XCTAssertEqual(ChatProseRenderCache.entryCount, 1) + } + + // MARK: - LRU bound + + func testEvictionHoldsTheBoundAndTurnsOldestKeysOver() { + beginFresh() + let capacity = 192 + var produced = Set<String>() + func produce(_ text: String) -> NSAttributedString { + produced.insert(text) + return NSAttributedString(string: text) + } + + for index in 0..<(capacity + 10) { + _ = ChatProseRenderCache.entry(for: Self.key(markdown: "row-\(index)")) { + produce("row-\(index)") + } + } + XCTAssertEqual( + ChatProseRenderCache.entryCount, capacity, + "the cache must not grow past its bound no matter how many keys arrive") + + // The oldest key was evicted: asking for it runs produce again. + produced.removeAll() + _ = ChatProseRenderCache.entry(for: Self.key(markdown: "row-0")) { produce("row-0") } + XCTAssertEqual(produced, ["row-0"], "the oldest key must have been evicted") + + // The newest key survived: it hits without producing. + produced.removeAll() + let newest = ChatProseRenderCache.entry(for: Self.key(markdown: "row-\(capacity + 9)")) { + produce("row-\(capacity + 9)") + } + XCTAssertTrue(produced.isEmpty, "a recently inserted key must not be evicted by later inserts") + XCTAssertNotNil(newest) + XCTAssertEqual(ChatProseRenderCache.entryCount, capacity) + } + + // MARK: - Height memo + + func testHeightMemoizesPerWidthAndMeasuresNewWidths() { + beginFresh() + let entry = ChatProseRenderCache.Entry(attributed: NSAttributedString(string: "prose")) + var measures = 0 + + let first = ChatProseRenderCache.height(for: entry, width: 320) { + measures += 1 + return 42 + } + let again = ChatProseRenderCache.height(for: entry, width: 320) { + measures += 1 + return 999 + } + let otherWidth = ChatProseRenderCache.height(for: entry, width: 480) { + measures += 1 + return 87 + } + let otherWidthAgain = ChatProseRenderCache.height(for: entry, width: 480) { + measures += 1 + return 12 + } + + XCTAssertEqual(first, 42) + XCTAssertEqual(again, 42, "the same width must reuse the measured height") + XCTAssertEqual(otherWidth, 87) + XCTAssertEqual(otherWidthAgain, 87) + XCTAssertEqual(measures, 2, "one measure per distinct width, none more") + } + + func testTheHeightMapDropsStaleWidthsAtTheBound() { + beginFresh() + let entry = ChatProseRenderCache.Entry(attributed: NSAttributedString(string: "prose")) + var measures = 0 + func measure() -> CGFloat { + measures += 1 + return CGFloat(measures) + } + + // Fill the bound with distinct widths. + for width in 1...8 { + _ = ChatProseRenderCache.height(for: entry, width: CGFloat(width), measure: measure) + } + XCTAssertEqual(measures, 8) + + // A ninth width evicts the map rather than growing it, so the first width + // — memoized above — has to measure again. + _ = ChatProseRenderCache.height(for: entry, width: 9, measure: measure) + let heightAfterDrop = ChatProseRenderCache.height(for: entry, width: 1, measure: measure) + XCTAssertEqual(measures, 10, "width 1 must have been re-measured after the drop") + XCTAssertEqual(heightAfterDrop, 10) + } + + // MARK: - Helpers + + private static func key( + markdown: String, + style: OmiMarkdown.Style = .assistant, + fontSize: Int = 14, + fontScaleMilli: Int = 1_000, + citationOrdinals: [Int] = [] + ) -> ChatProseRenderCache.Key { + ChatProseRenderCache.Key( + markdown: markdown, + style: style, + fontSize: fontSize, + fontScaleMilli: fontScaleMilli, + citationOrdinals: citationOrdinals) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift new file mode 100644 index 00000000000..d7bbb2ff82d --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift @@ -0,0 +1,287 @@ +import AppKit +import SwiftUI +import XCTest + +@testable import Omi_Computer + +/// **Selection lives in the transcript now.** +/// +/// It used to live in a popover beside the row, because SwiftUI's own selection +/// is barred here for good (FC-selection-overlay-layout-loop: PR #10834 +/// reopened it in Omi Beta 0.12.146). The bar is on `SelectionOverlay`, not on +/// selecting — an `NSTextView` *is* one selection, with no per-`Text` overlay to +/// install — so the words themselves are the surface now, on the reader's own +/// turns as much as Omi's. +@MainActor +final class ChatSelectableProseTests: XCTestCase { + private func attributed( + _ markdown: String, + style: OmiMarkdown.Style = .assistant, + citations: Set<Int> = [] + ) throws -> NSAttributedString { + try XCTUnwrap( + ChatSelectableProse.attributedString( + markdown: markdown, style: style, fontSize: 14, fontScale: 1, citationOrdinals: citations)) + } + + private func attribute( + _ key: NSAttributedString.Key, of text: NSAttributedString, at substring: String + ) throws -> Any? { + let range = try XCTUnwrap( + text.string.range(of: substring), "\(substring) is not in \(text.string)") + return text.attribute( + key, at: text.string.distance(from: text.string.startIndex, to: range.lowerBound), effectiveRange: nil) + } + + func testTheProseViewIsSelectableAndNotEditable() { + let view = ChatProseTextView() + view.isEditable = false + view.isSelectable = true + XCTAssertTrue(view.isSelectable, "selecting the answer is the entire point") + XCTAssertFalse(view.isEditable, "a transcript row is not a document the reader may rewrite") + } + + /// Both senders. A user turn was never selectable by any means — the popover + /// was reachable from the hover strip, and a user row has no hover strip. + func testBothSendersRenderSelectableProse() throws { + for style in [OmiMarkdown.Style.assistant, .user] { + let text = try attributed("Booking confirmed.", style: style) + XCTAssertEqual(text.string, "Booking confirmed.") + } + } + + func testEmphasisSurvivesTheCrossingIntoAppKit() throws { + let text = try attributed("Do **YC application** with *Nick* and run `agentctl`.") + XCTAssertEqual(text.string, "Do YC application with Nick and run agentctl.") + + let bold = try XCTUnwrap(try attribute(.font, of: text, at: "YC application") as? NSFont) + XCTAssertTrue( + bold.fontDescriptor.symbolicTraits.contains(.bold), "bold must not flatten into body text") + + let italic = try XCTUnwrap(try attribute(.font, of: text, at: "Nick") as? NSFont) + XCTAssertTrue(italic.fontDescriptor.symbolicTraits.contains(.italic)) + + let code = try XCTUnwrap(try attribute(.font, of: text, at: "agentctl") as? NSFont) + XCTAssertTrue( + code.fontDescriptor.symbolicTraits.contains(.monoSpace), + "inline code keeps its monospace face now that it is text rather than a button") + XCTAssertNotNil( + try attribute(.backgroundColor, of: text, at: "agentctl"), "and keeps its chip wash") + } + + /// The marker stays inside the one text view, so it is draggable and + /// copyable — which the chip button never was — and still opens its source. + func testAKnownCitationMarkerBecomesAnOpenableLink() throws { + let text = try attributed("You favoured the clearer concept. [1]", citations: [1]) + let link = try XCTUnwrap(try attribute(.link, of: text, at: "[1]") as? URL) + XCTAssertEqual(ChatSelectableProse.citationOrdinal(from: link), 1) + } + + /// Ordinals run to four digits, and the model also writes the kind alongside + /// them. Both used to fall outside a narrower pattern and render as dead text. + func testWideAndKindPrefixedMarkersAreLinkedToo() throws { + let wide = try attributed("You preferred the clearer direction. [5004]", citations: [5004]) + XCTAssertEqual( + ChatSelectableProse.citationOrdinal( + from: try XCTUnwrap(try attribute(.link, of: wide, at: "[5004]") as? URL)), + 5004) + + let prefixed = try attributed("You prefer mornings. [memory 5023]", citations: [5023]) + XCTAssertEqual( + ChatSelectableProse.citationOrdinal( + from: try XCTUnwrap(try attribute(.link, of: prefixed, at: "[memory 5023]") as? URL)), + 5023) + } + + /// A bracketed number the turn has no source for is prose, not a control. + func testAnUnknownBracketedNumberIsLeftAsWords() throws { + let text = try attributed("Section [4] of the lease.", citations: [1]) + XCTAssertNil(try attribute(.link, of: text, at: "[4]")) + } + + func testProseKeepsTheTranscriptsOwnLeading() throws { + let text = try attributed("One line.") + let paragraph = try XCTUnwrap( + try attribute(.paragraphStyle, of: text, at: "One") as? NSParagraphStyle) + XCTAssertEqual(paragraph.lineSpacing, OmiMarkdownContent.chatLineSpacing(fontSize: 14)) + } +} + +/// A turn cut off by a barge-in used to render exactly like a finished one. +final class ChatTurnFailurePresentationTests: XCTestCase { + private func failed(_ text: String, blocks: [ChatContentBlock] = []) -> ChatMessage { + ChatMessage( + id: "t", text: text, sender: .ai, isStreaming: false, contentBlocks: blocks, + journalStatus: .failed) + } + + func testAFailedTurnWithPartialTextIsMarkedTruncated() { + XCTAssertEqual( + ChatTurnFailurePresentation.of(failed("They arrive on Saturday,")), .truncatedAnswer) + } + + func testAFailedTurnWithNothingToShowKeepsTheStamp() { + XCTAssertEqual(ChatTurnFailurePresentation.of(failed("")), .emptyTurnStamp) + } + + func testAFailedTurnThatOnlyProducedBlocksIsStillMarkedTruncated() { + XCTAssertEqual( + ChatTurnFailurePresentation.of(failed("", blocks: [.text(id: "b", text: "partial")])), + .truncatedAnswer) + } + + func testACompletedTurnIsNotAFailure() { + let done = ChatMessage( + id: "t", text: "All set.", sender: .ai, isStreaming: false, journalStatus: .completed) + XCTAssertEqual(ChatTurnFailurePresentation.of(done), .none) + } + + /// A turn still streaming has not failed yet, whatever the last journal row said. + func testAStreamingRowIsNeverPresentedAsFailed() { + let live = ChatMessage( + id: "t", text: "They arrive on Sat", sender: .ai, isStreaming: true, journalStatus: .failed) + XCTAssertEqual(ChatTurnFailurePresentation.of(live), .none) + } +} + +/// Each push-to-talk press mints a distinct journal turn, so the same short +/// answer three times is three legitimate rows. Collapsing them is a *display* +/// decision, and it has to be narrow enough not to eat a real repeated answer. +final class ChatShortDuplicateCollapseTests: XCTestCase { + private let shortAnswer = + "They arrive on Saturday, and the booking is already confirmed." + + private func msg( + _ id: String, _ text: String, sender: ChatSender = .ai, offset: TimeInterval = 0, + status: KernelJournalTurnStatus? = nil + ) -> ChatMessage { + ChatMessage( + id: id, text: text, createdAt: Date(timeIntervalSince1970: 1_700_000_000 + offset), + sender: sender, journalStatus: status) + } + + func testConsecutiveIdenticalShortAnswersCollapse() { + let messages = [ + msg("1", shortAnswer), + msg("2", shortAnswer, offset: 30), + msg("3", shortAnswer, offset: 60), + ] + XCTAssertEqual(ChatMessageDeduplicator.duplicateIDs(in: messages), ["2", "3"]) + } + + /// The same sentence answering a different question later is not a stutter. + func testIdenticalShortAnswersSeparatedByAnotherTurnDoNotCollapse() { + let messages = [ + msg("1", shortAnswer), + msg("q", "and the flight?", sender: .user, offset: 10), + msg("2", shortAnswer, offset: 20), + ] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } + + func testIdenticalShortAnswersFarApartInTimeDoNotCollapse() { + let messages = [msg("1", shortAnswer), msg("2", shortAnswer, offset: 3_600)] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } + + func testTheSameWordsFromDifferentSendersDoNotCollapse() { + let messages = [msg("1", shortAnswer, sender: .user), msg("2", shortAnswer, offset: 5)] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } + + /// A barge-in fragment followed by the answer it was cut out of. + func testAFailedFragmentCollapsesIntoTheCompleteAnswerBelowIt() { + let messages = [ + msg("fragment", "They arrive on Saturday, and the", offset: 0, status: .failed), + msg("full", shortAnswer, offset: 20), + ] + XCTAssertEqual(ChatMessageDeduplicator.duplicateIDs(in: messages), ["fragment"]) + } + + /// The observed screenshot: the answer twice, then a third try cut off. + func testATruncatedRetryAfterACompleteAnswerCollapses() { + let messages = [ + msg("1", shortAnswer), + msg("2", shortAnswer, offset: 20), + msg("3", "They arrive on Saturday,", offset: 40, status: .failed), + ] + XCTAssertEqual(ChatMessageDeduplicator.duplicateIDs(in: messages), ["2", "3"]) + } + + /// A one-word "Done." repeated is not a stutter worth a chip. + func testVeryShortRepeatsStayBelowTheFloor() { + let messages = [msg("1", "Done."), msg("2", "Done.", offset: 5)] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } +} + +/// The rhythm complaint, measured on the real views rather than argued about. +@MainActor +final class ChatTranscriptRowRhythmTests: XCTestCase { + private static let width: CGFloat = 520 + + private func message( + _ id: String, _ text: String, sender: ChatSender = .ai, + blocks: [ChatContentBlock] = [] + ) -> ChatMessage { + ChatMessage( + id: id, text: text, createdAt: Date(timeIntervalSince1970: 1_700_000_000), sender: sender, + isStreaming: false, isSynced: true, contentBlocks: blocks) + } + + private func rowHeight(_ message: ChatMessage) -> CGFloat { + NSHostingView( + rootView: ChatBubble(message: message, app: nil, showsOmiMark: true, onRate: { _, _ in }) + .frame(width: Self.width) + ).fittingSize.height + } + + /// **The complaint, in numbers.** Two consecutive one-line answers sat roughly + /// 100 device pixels apart: a 28 pt reserved hover band *plus* a full 16 pt + /// inter-exchange gap, on top of a row that reserved 32 pt for a mark it did + /// not need. The band is separation; the gap must not be charged twice. + func testTwoConsecutiveShortAnswersAreNotSeparatedByHalfALineOfNothing() { + let first = message("a0", "They arrive on Saturday.") + let second = message("a1", "The booking is confirmed.") + let gap = ChatTranscriptLayout.spacing(from: first, to: second) + let deadSpaceUnderTheRow = ChatBubbleMetadataControlMetrics.bandHeight + gap + + XCTAssertLessThanOrEqual( + deadSpaceUnderTheRow, 32, + "a settled answer must not float in more than 64 device pixels of nothing") + XCTAssertEqual(gap, ChatTranscriptLayout.afterMetadataBandRowSpacing) + } + + /// A card-only row has nothing to copy or rate and stamps its own time, so it + /// reserves no band — which is what left the memory card floating. + func testACardOnlyRowReservesNoMetadataBand() { + let card = message( + "card", "", blocks: [.discoveryCard(id: "b", title: "Memory", summary: "summary", fullText: "full")]) + XCTAssertEqual(ChatBubbleMetadataBand.of(card), .hidden) + XCTAssertEqual( + ChatTranscriptLayout.spacing(from: card, to: message("a", "next")), + ChatTranscriptLayout.regularRowSpacing, + "with no band of its own the card takes the ordinary exchange gap") + } + + /// A row with nothing at all still keeps its timestamp — that stamp is all it + /// has to say it happened. + func testAnEmptyCompletedRowStillKeepsItsTimestamp() { + XCTAssertEqual(ChatBubbleMetadataBand.of(message("empty", "")), .timestampOnly) + } + + /// The 32 pt reservation exists for an empty streaming reply, whose own + /// content has no height. On a settled row it only centred short content in a + /// box taller than itself. + func testASettledShortRowDoesNotReserveTheStreamingMarkHeight() { + let settled = message("a0", "Yes.") + let streaming = ChatMessage( + id: "a1", text: "", sender: .ai, isStreaming: true, isSynced: false) + + XCTAssertGreaterThanOrEqual(rowHeight(streaming), ChatOmiMarkPlacement.reservedRowHeight - 1) + XCTAssertLessThan( + rowHeight(settled), + ChatOmiMarkPlacement.reservedRowHeight + ChatBubbleMetadataControlMetrics.bandHeight, + "a one-line answer plus its band must not be padded out to the mark's box plus its band") + } +} diff --git a/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift b/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift index 48150338944..1cbd34b7ba7 100644 --- a/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift +++ b/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift @@ -203,6 +203,61 @@ final class ChatScrollLiveEdgeTests: XCTestCase { } } +/// Whether viewport movement observed during a press is the reader's doing. +final class ChatPressPromotionPolicyTests: XCTestCase { + private let epsilon: CGFloat = 1 + + func testMovementNobodyClaimedBelongsToTheReader() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, epsilon: epsilon, now: 100, lastProgrammaticScrollAt: nil), + .promotesPress, + "with no follow-scroll to account for it, a moved viewport is the reader's doing") + } + + func testTheTranscriptsOwnFollowScrollDoesNotClaimTheViewportForTheReader() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, epsilon: epsilon, now: 100.05, lastProgrammaticScrollAt: 100), + .rebaselines, + "a streamed answer re-reaching the live edge under an open press is not a drag") + } + + func testMovementLongAfterTheLastFollowScrollIsStillTheReaders() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, + epsilon: epsilon, + now: 100 + ChatPressPromotionPolicy.programmaticScrollGrace + 0.01, + lastProgrammaticScrollAt: 100), + .promotesPress, + "the grace window covers one runloop hop, not the rest of the press") + } + + func testAStationaryViewportMeansNothingEitherWay() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 0.4, epsilon: epsilon, now: 100, lastProgrammaticScrollAt: nil), + .ignores, + "a click that moved nothing is still just a click") + } + + func testABackwardsClockCannotDiscountReaderMovementForever() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, epsilon: epsilon, now: 99, lastProgrammaticScrollAt: 100), + .promotesPress, + "a clock that went backwards must not leave the reader unable to take the viewport") + } + + func testTheSignalStartsWithNothingToAccountFor() { + let signal = ChatProgrammaticScrollSignal() + XCTAssertNil(signal.lastScrollAt) + signal.markProgrammaticScroll(at: 42) + XCTAssertEqual(signal.lastScrollAt, 42) + } +} + /// AppKit-backed failure harness for chat scroll ownership. Unlike the /// coordinate-only live-edge cases above, these tests drive the same native /// live-scroll lifecycle emitted by a rapid trackpad/wheel gesture. diff --git a/desktop/macos/Desktop/Tests/ChatStreamingRevealTests.swift b/desktop/macos/Desktop/Tests/ChatStreamingRevealTests.swift new file mode 100644 index 00000000000..a2f60f25544 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatStreamingRevealTests.swift @@ -0,0 +1,81 @@ +import XCTest + +@testable import Omi_Computer + +/// The wire delivers an answer in bursts; the transcript should show it as a +/// flow. These pin the pacing that turns one into the other. +final class ChatStreamingRevealTests: XCTestCase { + func testASmallBacklogDrainsOverAFewFlushes() { + XCTAssertEqual(ChatStreamingReveal.characters(pending: 100), 20) + XCTAssertEqual(ChatStreamingReveal.characters(pending: 10), ChatStreamingReveal.minimumPerFlush) + XCTAssertEqual(ChatStreamingReveal.characters(pending: 3), 3, "never more than is pending") + XCTAssertEqual(ChatStreamingReveal.characters(pending: 0), 0) + } + + func testALargeBacklogCatchesUpToTheLagCap() { + let revealed = ChatStreamingReveal.characters(pending: 2000) + XCTAssertEqual(2000 - revealed, ChatStreamingReveal.maximumLag, "a paragraph from a tool lands almost at once") + } + + private func makeMessages() -> [ChatMessage] { + [ChatMessage(id: "m", text: "", sender: .ai, isStreaming: true)] + } + + func testAPacedFlushRevealsASliceAndKeepsTheRestQueued() { + let buffer = ChatStreamingBuffer(flushInterval: 10) + var messages = makeMessages() + buffer.appendText(messageId: "m", text: String(repeating: "a", count: 100), scheduleFlush: {}) + + XCTAssertTrue(buffer.flushPaced(messages: &messages), "most of the burst is still waiting") + XCTAssertEqual(messages[0].text.count, 20) + XCTAssertEqual(buffer.pendingTextCount, 80) + + XCTAssertTrue(buffer.flushPaced(messages: &messages)) + XCTAssertEqual(messages[0].text.count, 36, "each flush takes a fifth of what remains") + + buffer.flush(messages: &messages) + XCTAssertEqual(messages[0].text.count, 100) + XCTAssertFalse(buffer.flushPaced(messages: &messages), "nothing left once drained") + } + + func testThinkingStaysBehindTheTextItFollows() { + let buffer = ChatStreamingBuffer(flushInterval: 10) + var messages = makeMessages() + buffer.appendText(messageId: "m", text: String(repeating: "a", count: 100), scheduleFlush: {}) + buffer.appendThinking(messageId: "m", text: "thought", scheduleFlush: {}) + buffer.appendText(messageId: "m", text: String(repeating: "b", count: 10), scheduleFlush: {}) + + XCTAssertTrue(buffer.flushPaced(messages: &messages)) + XCTAssertEqual(messages[0].contentBlocks.count, 1, "the thinking block waits for the text ahead of it") + XCTAssertEqual(messages[0].text, String(repeating: "a", count: 22)) + + buffer.flush(messages: &messages) + XCTAssertEqual(messages[0].contentBlocks.count, 3) + XCTAssertEqual(messages[0].text, String(repeating: "a", count: 100) + String(repeating: "b", count: 10)) + } + + func testAPacedFlushCutsOnCharacterBoundaries() { + let buffer = ChatStreamingBuffer(flushInterval: 10) + var messages = makeMessages() + // Family emoji are one Character of several scalars; a cut must not split one. + let text = String(repeating: "👨‍👩‍👧", count: 30) + buffer.appendText(messageId: "m", text: text, scheduleFlush: {}) + + _ = buffer.flushPaced(messages: &messages) + XCTAssertEqual(messages[0].text, String(repeating: "👨‍👩‍👧", count: 6)) + buffer.flush(messages: &messages) + XCTAssertEqual(messages[0].text, text) + } + + func testTheRemainderReArmsItsOwnFlush() { + let buffer = ChatStreamingBuffer(flushInterval: 0.01) + var messages = makeMessages() + let rearmed = expectation(description: "a paced flush that leaves text behind schedules the next") + buffer.appendText(messageId: "m", text: String(repeating: "a", count: 100), scheduleFlush: {}) + + XCTAssertTrue(buffer.flushPaced(messages: &messages)) + buffer.scheduleFlush { rearmed.fulfill() } + + wait(for: [rearmed], timeout: 1) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift b/desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift new file mode 100644 index 00000000000..d1813719857 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift @@ -0,0 +1,46 @@ +import SwiftUI + +@testable import Omi_Computer + +/// Test seam for the two Chat views whose content-block context is now required. +/// +/// Most of these assertions are about row shape, hover regions, or view +/// identity, not about content blocks. They bind the same auxiliary context the +/// task panel and the notch use, so a test never asserts on a projection no +/// production surface has. +@MainActor +enum ChatSurfaceTestContext { + static func make(chatProvider: ChatProvider? = nil) -> ChatFirstRichBlockContext { + .auxiliary(chatProvider: chatProvider ?? ChatProvider.mainInstance ?? ChatProvider()) + } +} + +@MainActor +extension ChatBubble { + init( + message: ChatMessage, + app: OmiApp?, + showsOmiMark: Bool, + onRate: @escaping (Int?, ChatFeedbackReason?) -> Void, + onCitationTap: ((Citation) -> Void)? = nil, + onOpenInlineCitation: ((ChatCitationReference) -> Void)? = nil, + isDuplicate: Bool = false, + onCancelTurn: (() -> Void)? = nil, + onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil, + onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil + ) { + self.init( + message: message, + app: app, + showsOmiMark: showsOmiMark, + onRate: onRate, + onCitationTap: onCitationTap, + onOpenInlineCitation: onOpenInlineCitation, + isDuplicate: isDuplicate, + onCancelTurn: onCancelTurn, + onOpenAgent: onOpenAgent, + onOpenAgentRef: onOpenAgentRef, + chatFirstRichBlockContext: ChatSurfaceTestContext.make() + ) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift b/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift index b31886cec93..f65c7c26fd8 100644 --- a/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift @@ -254,6 +254,55 @@ final class ChatTimelineContinuityTests: XCTestCase { XCTAssertEqual(settled.copyableText, "You filmed the launch video and tested the memory graph.") } + /// A turn that answers with cards writes no prose, so the runtime synthesizes + /// one line per block for clients that cannot draw them and puts it on the + /// message's ordinary text field. The desktop draws the cards, so printing + /// that line too rendered the goal card, three task cards, and then + /// "Goal - Make Omi Great Again / Task / Task / Task" underneath them. + func testTheCardsOwnDegradationIsNotPrintedUnderTheCards() { + let blocks: [ChatContentBlock] = [ + .goalLink(id: "goal_1", goalId: "g-1", summary: "Make Omi Great Again"), + .taskCard(id: "task_1", taskId: "t-1"), + .taskCard(id: "task_2", taskId: "t-2"), + .taskCard(id: "task_3", taskId: "t-3"), + ] + let projection = "Goal - Make Omi Great Again\nTask\nTask\nTask" + XCTAssertEqual( + ChatStructuredFallbackText.projected(blocks), projection, + "the desktop has to recognize the exact text the runtime synthesizes") + XCTAssertEqual( + ChatAssistantAnswerText.visible( + contentBlocks: blocks, fallback: projection, isStreaming: false), + "") + } + + /// The suppression is keyed on the body *being* that projection, not on the + /// turn merely having cards — an answer the model actually wrote still reads. + func testProseWrittenAlongsideCardsSurvives() { + let blocks: [ChatContentBlock] = [ + .taskCard(id: "task_1", taskId: "t-1"), + .text(id: "text_1", text: "Start with the hackathon — the deadline is closest."), + ] + XCTAssertEqual( + ChatAssistantAnswerText.visible( + contentBlocks: blocks, + fallback: "Start with the hackathon — the deadline is closest.", + isStreaming: false), + "Start with the hackathon — the deadline is closest.") + } + + /// Whitespace is normalized on both sides, the way mobile's + /// `textIsStructuredFallback` does it, so a re-wrapped body is still + /// recognized as the projection rather than printed back. + func testAReflowedProjectionIsStillRecognized() { + let blocks: [ChatContentBlock] = [ + .memoryLink(id: "memory_1", memoryId: "m-1", summary: "Prefers dark mode") + ] + XCTAssertTrue( + ChatStructuredFallbackText.bodyIsBlockProjection( + text: " Memory - Prefers dark mode ", contentBlocks: blocks)) + } + func testSettledPreToolTextRemainsWhenItIsTheOnlyAnswer() { let blocks: [ChatContentBlock] = [ .text(id: "text_1", text: "I started a background agent for that."), @@ -1531,30 +1580,29 @@ final class ChatTimelineContinuityTests: XCTestCase { // Home is the only main-window chat surface now, so the assertions the // standalone chat page used to carry move onto it rather than retiring. - let dashboard = try String( - contentsOf: root.appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift"), + // `QueryAnswerThread` is that surface (`DashboardPage` and its inline chat + // are gone), and it now binds the provider directly — the goal-citation + // rewrite that used to sit between them belonged to the deleted shell. + let answerThread = try String( + contentsOf: root.appendingPathComponent("Sources/MainWindow/QueryShell/QueryAnswerThread.swift"), encoding: .utf8) XCTAssertGreaterThanOrEqual( - dashboard.components(separatedBy: "messages: chatProvider.messages,").count - 1, - 2, + answerThread.components(separatedBy: "messages: chatProvider.messages,").count - 1, + 1, "Home chat surfaces must bind the shared ChatProvider timeline" ) XCTAssertFalse( - dashboard.contains("transcriptMessages"), + answerThread.contains("transcriptMessages"), "Home chat must not filter notch/PTT turns out of history" ) XCTAssertTrue( - dashboard.contains("openAgentChatFromTimeline(agentID: agentID, completion: completion)"), + answerThread.contains("openAgentChatFromTimeline(\n agentID: agentID, completion: completion)"), "Home chat must open spawned-agent links from the timeline with open result feedback" ) XCTAssertTrue( - dashboard.contains("openAgentChatFromTimeline(ref: ref, completion: completion)"), + answerThread.contains("openAgentChatFromTimeline(\n ref: ref, completion: completion)"), "Home chat must open structured agent refs with open result feedback" ) - XCTAssertFalse( - dashboard.contains("transcriptMessages"), - "Home chat must not filter notch/PTT turns out of history" - ) let floatingState = try String( contentsOf: root.appendingPathComponent("Sources/FloatingControlBar/FloatingControlBarState.swift"), diff --git a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift index 7d2e7697fd1..b9a65bbbae3 100644 --- a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift @@ -217,6 +217,35 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { "transcript rows stop at x=\(painted) of \(viewportWidth), so a gutter is still reserved") } + /// Selection has to be in the mounted transcript, on both senders' rows. + /// The unit tests prove the attributed string; this proves the transcript + /// actually hosts the text view that owns a selection, which is the part a + /// wiring mistake would silently drop. + func testEveryMountedRowHostsSelectableText() throws { + let harness = try makeHarness(messageCount: 12) + defer { harness.tearDown() } + harness.settleInitialPlacement() + + var leadingEdges = Set<CGFloat>() + var selectable = 0 + func walk(_ view: NSView) { + if let text = view as? ChatProseTextView { + XCTAssertTrue(text.isSelectable, "a mounted row that cannot be selected is the old bug") + XCTAssertFalse(text.isEditable, "a transcript row is not a document") + leadingEdges.insert(text.convert(text.bounds, to: nil).origin.x) + selectable += 1 + } + view.subviews.forEach(walk) + } + walk(harness.scrollView) + + XCTAssertGreaterThan(selectable, 0, "the transcript mounted no selectable prose at all") + XCTAssertGreaterThan( + leadingEdges.count, 1, + "user and assistant rows start at different insets, so one inset means only one sender " + + "is selectable — which is exactly what the popover era looked like") + } + func testRepeatedFastBurstsKeepTheMountedTranscriptResponsive() throws { let harness = try makeHarness(messageCount: 120) defer { harness.tearDown() } @@ -326,6 +355,59 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { + "(scrollTop=\(harness.scrollTop) of \(harness.maximumScrollTop))") } + /// A press inside the transcript is ordinary now that every content block is + /// something you can click, and the transcript re-reaches the live edge every + /// `ChatScrollFollowThrottle.interval` while an answer streams. Reading its + /// own follow-scroll as "the reader took the viewport" would abandon the + /// reader for the rest of the answer, so the press-promotion test in + /// `ChatPressPromotionPolicy` discounts movement the app just caused. + func testAClickWhileAnAnswerStreamsDoesNotStopTheTranscriptFollowingIt() throws { + let harness = try makeHarness() + defer { harness.tearDown() } + harness.settleInitialPlacement() + XCTAssertTrue(harness.isAtBottom, "precondition: the transcript opens at the live edge") + + harness.sendLeftMouseDown() + var worstDrift: CGFloat = 0 + for chunk in 0..<40 { + harness.appendStreamingText(" Streamed chunk \(chunk) with enough prose to grow the row. ") + harness.pump(0.035) + worstDrift = max(worstDrift, harness.maximumScrollTop - harness.scrollTop) + } + harness.sendLeftMouseUp() + + XCTAssertLessThan( + worstDrift, 120, + "a click that never moved the viewport must not end follow mode " + + "(drifted \(worstDrift) pt of a \(harness.viewportHeight) pt viewport)") + } + + /// A press whose release is delivered somewhere else — the "Select Text\u{2026}" + /// popover and context menus present in their own window — could leave the + /// press candidate open for the life of the scroll view, where the next + /// follow-scroll would promote it. The monitor now closes a press on any + /// release, whichever window carried it. + func testAPressReleasedInAnotherWindowDoesNotStrandTheTranscript() throws { + let harness = try makeHarness() + defer { harness.tearDown() } + harness.settleInitialPlacement() + + harness.sendLeftMouseDown() + harness.sendLeftMouseUp(inWindowNumber: harness.windowNumber + 4_242) + + var worstDrift: CGFloat = 0 + for chunk in 0..<40 { + harness.appendStreamingText(" Streamed chunk \(chunk) after the popover took the release. ") + harness.pump(0.035) + worstDrift = max(worstDrift, harness.maximumScrollTop - harness.scrollTop) + } + + XCTAssertLessThan( + worstDrift, 120, + "a release the transcript's window never saw must still close the press " + + "(drifted \(worstDrift) pt of a \(harness.viewportHeight) pt viewport)") + } + /// Dragging the scrollbar genuinely moves the viewport, so it must still take /// ownership away from live-follow. func testAMouseDragThatMovesTheViewportStillTakesOwnership() throws { @@ -435,6 +517,10 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { final class Harness { let model: TranscriptModel private let window: NSWindow + + /// The transcript's own window, so a test can address a release to some + /// other window the way a popover or a menu does. + var windowNumber: Int { window.windowNumber } private let hostingView: NSHostingView<HarnessChatHost> private var pendingMessages: [ChatMessage] = [] private(set) var scrollView: NSScrollView @@ -516,6 +602,19 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { let representation = clipView.bitmapImageRepForCachingDisplay(in: bounds) else { return nil } clipView.cacheDisplay(in: bounds, to: representation) + // `cacheDisplay` walks `draw(_:)`, which no longer sees everything: once + // the transcript hosts an AppKit text view its prose is drawn from a + // backing layer, and a bitmap taken this way shows the SwiftUI chrome + // without the words. Compositing the layer tree on top puts the text back + // in the picture, so the probe measures the row rather than half of it. + if let layer = clipView.layer, + let context = NSGraphicsContext(bitmapImageRep: representation) + { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + layer.render(in: context.cgContext) + NSGraphicsContext.restoreGraphicsState() + } guard let image = representation.cgImage else { return nil } let width = image.width @@ -653,11 +752,12 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { pump(0.05) } - func sendLeftMouseUp() { + func sendLeftMouseUp(inWindowNumber windowNumber: Int? = nil) { guard let event = NSEvent.mouseEvent( with: .leftMouseUp, location: NSPoint(x: 450, y: 300), modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber ?? window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 0) else { return } NSApplication.shared.sendEvent(event) @@ -867,6 +967,7 @@ struct HarnessChatHost: View { onRate: { _, _, _ in }, localSendToken: model.localSendToken, horizontalContentPadding: 0, + chatFirstRichBlockContext: ChatSurfaceTestContext.make(), transcriptWindowPolicy: model.transcriptWindowPolicy, welcomeContent: { EmptyView() } ) diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index b932b2591f1..4a13676cbb9 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -30,20 +30,6 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertEqual(CaptureListeningLogic.listeningStatus(appState: appState), .active) } - @MainActor - func testHomeListeningHelpDoesNotClaimOffWhileAwaitingAMeeting() { - let help = HomeListeningStatusButton.helpText( - status: .inactive, modeTitle: "Only Meetings", isAwaitingMeeting: true) - XCTAssertTrue(help.contains("waiting for a call")) - XCTAssertTrue(help.contains("Only Meetings")) - XCTAssertTrue(help.contains("Click to turn off")) - XCTAssertFalse(help.contains("Off")) - XCTAssertEqual( - HomeListeningStatusButton.helpText( - status: .inactive, modeTitle: "Always On", isAwaitingMeeting: false), - "Listening: Off, Always On") - } - @MainActor func testListeningModeTitlePreservesOakleyMetaName() { let appState = AppState() @@ -55,217 +41,6 @@ final class DashboardCaptureStateTests: XCTestCase { "Oakley Meta Vanguard") } - func testDashboardCaptureStatusUsesLiveMonitoringState() throws { - let source = try dashboardSource() - let logic = try captureLogicSource() - - // The header derives capture status from the shared CaptureListeningLogic… - XCTAssertTrue( - source.contains( - "CaptureListeningLogic.captureStatus(appState: appState, isCaptureMonitoring: isCaptureMonitoring)"), - "DashboardPage should derive capture status from the shared CaptureListeningLogic" - ) - // …which lights up from the LIVE monitor, never stale persisted intent. - XCTAssertTrue( - logic.contains("return isCaptureLive(isCaptureMonitoring: isCaptureMonitoring) ? .active : .inactive"), - "Capture status should light up when monitoring is live, even if persisted intent is stale" - ) - XCTAssertTrue( - logic.contains("isCaptureMonitoring || ProactiveAssistantsPlugin.shared.isMonitoring"), - "Live capture state must reflect the running monitor" - ) - XCTAssertFalse( - logic.contains("if screenAnalysisEnabled && isCaptureMonitoring {\n return .active\n }"), - "Capture status must not require persisted intent to match the live monitor" - ) - } - - func testDashboardCaptureToggleDerivesFromLiveState() throws { - let source = try dashboardSource() - let logic = try captureLogicSource() - - XCTAssertTrue( - source.contains("CaptureListeningLogic.toggleCapture("), - "DashboardPage's capture toggle should route through the shared CaptureListeningLogic" - ) - XCTAssertTrue( - logic.contains( - "syncCaptureState(screenAnalysisEnabled: screenAnalysisEnabled, isCaptureMonitoring: isCaptureMonitoring)"), - "Capture toggles should reconcile the live monitor before deciding whether the click starts or stops capture" - ) - XCTAssertTrue( - logic.contains("let enabled = !isCaptureLive(isCaptureMonitoring: isCaptureMonitoring.wrappedValue)"), - "Capture toggles should derive the next state from the live monitor" - ) - XCTAssertFalse( - logic.contains("let enabled = !screenAnalysisEnabled"), - "Capture toggles should not derive from stale persisted intent" - ) - } - - func testListeningPillReflectsTheUnifiedAudioRecordingMode() throws { - let source = try dashboardSource() - let logic = try captureLogicSource() - - XCTAssertTrue(source.contains("@AppStorage(AssistantSettings.audioRecordingModeDefaultsKey)")) - XCTAssertTrue(source.contains("private var listeningModeTitle: String")) - XCTAssertTrue(logic.contains("return appState.isAwaitingMeeting ? \"Only Meetings\" : \"In Meeting\"")) - XCTAssertTrue(source.contains("HomeListeningStatusButton(")) - XCTAssertFalse(source.contains("modeAction: toggleListeningMode")) - XCTAssertFalse(logic.contains("toggleListeningMode")) - XCTAssertTrue(source.contains(".frame(height: 34)")) - XCTAssertFalse(source.contains("Circle()\n .fill(status.indicator)")) - XCTAssertFalse(source.contains("OmiColors.purplePrimary")) - } - - func testListeningStatusIsSharedAndLiveTranscriptExpandReplacesThePage() throws { - let dashboard = try dashboardSource() - let logic = try captureLogicSource() - let conversations = try source(named: "ConversationsPage.swift") - let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let shellURL = - testsURL - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/QueryShell/ShellStatusIcons.swift") - // omi-test-quality: source-inspection -- static contract: which predicate the Live card and listening dot name is not observable from a running view without a window server - let shell = try String(contentsOf: shellURL, encoding: .utf8) - - XCTAssertTrue(logic.contains("return appState.isLiveCapturing ? .active : .inactive")) - XCTAssertTrue(dashboard.contains("CaptureListeningLogic.listeningStatus(appState: appState)")) - XCTAssertTrue(dashboard.contains("isAwaitingMeeting: appState.isAwaitingMeeting")) - XCTAssertTrue(shell.contains("CaptureListeningLogic.listeningStatus(appState: appState)")) - XCTAssertTrue(conversations.contains("if appState.isLiveCapturing {")) - XCTAssertTrue(conversations.contains("if isLiveTranscriptExpanded && appState.isLiveCapturing")) - XCTAssertFalse( - conversations.contains(".overlay {\n if isLiveTranscriptExpanded"), - "Expanding the live transcript must replace the Conversations page body, not overlay it.") - } - - func testRedesignedHomeUsesResponsiveStageSizing() throws { - let source = try dashboardSource() - - XCTAssertTrue(source.contains("private static let homeStageMaxWidth: CGFloat = 1360")) - XCTAssertTrue(source.contains("private static let homeAskBarMinWidth: CGFloat = 560")) - XCTAssertTrue(source.contains("private static let homeStagePanelMaxWidth: CGFloat = 1280")) - XCTAssertTrue(source.contains("private func homeStageSideInset(for stageWidth: CGFloat) -> CGFloat")) - XCTAssertTrue(source.contains("private func homeHubAskBarWidth(for stageWidth: CGFloat, draft: String) -> CGFloat")) - XCTAssertTrue( - source.contains("(text as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: 15)]).width")) - XCTAssertTrue(source.contains("private func homeHubStage(stageWidth: CGFloat) -> some View")) - XCTAssertTrue(source.contains("private var homeHubHeadline: some View")) - XCTAssertFalse(source.contains(".frame(width: 304)")) - XCTAssertFalse(source.contains(".frame(maxWidth: Self.homeAskBarMaxWidth)")) - XCTAssertFalse(source.contains(".frame(maxWidth: Self.homeStagePanelMaxWidth)")) - } - - func testHomeAskBarRefocusesAfterOpeningChatStage() throws { - let source = try dashboardSource() - let openChat = try methodBody(named: "openHomeChat", in: source) - - XCTAssertTrue(source.contains("private func openHomeChat(focusInput: Bool = true)")) - XCTAssertTrue(source.contains("focusHomeAskFieldAfterStageTransition()")) - XCTAssertTrue(source.contains("await Task.yield()")) - XCTAssertTrue(source.contains("homeAskFieldFocused = true")) - XCTAssertTrue(source.contains("openHomeChat(focusInput: false)")) - // omi-test-quality: source-inspection -- static contract: the SwiftUI focus - // state and navigation method are private view wiring, so the hotkey's - // already-visible-chat path cannot be driven from the test host. - XCTAssertTrue( - openChat.contains("if homeMode != .chat {"), - "Opening an already-visible chat must still continue to the input-focus request") - XCTAssertFalse( - openChat.contains("guard homeMode != .chat else { return }"), - "An early return drops the hotkey's input focus when chat is already visible") - } - - func testSecondaryHomePagesReturnHomeOnEscape() { - for item in [SidebarNavItem.conversations, .memories, .tasks, .rewind] { - XCTAssertTrue( - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: item.rawValue, - usesLegacyHomeDesign: false - )) - } - // `.chat` was removed from `SidebarNavItem` when the standalone chat page was deleted. Escape on - // Home itself still must not navigate home, so the case moves to the destination Home now is. - XCTAssertFalse( - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: SidebarNavItem.dashboard.rawValue, - usesLegacyHomeDesign: false - )) - XCTAssertFalse( - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: SidebarNavItem.tasks.rawValue, - usesLegacyHomeDesign: true - )) - } - - func testHomeConnectorButtonsOpenSheetsDirectly() throws { - let source = try dashboardSource() - let importMethod = try methodBody(named: "openImportConnector", in: source) - let exportMethod = try methodBody(named: "openExportDestination", in: source) - - XCTAssertTrue(source.contains("@State private var selectedImportConnector: ImportConnector?")) - XCTAssertTrue(source.contains("@State private var selectedExportDestination: MemoryExportDestination?")) - XCTAssertFalse(source.contains(".dismissableSheet(item: $selectedImportConnector)")) - XCTAssertFalse(source.contains(".dismissableSheet(item: $selectedExportDestination)")) - XCTAssertTrue(importMethod.contains("presentImportConnector(connector)")) - XCTAssertTrue(exportMethod.contains("presentExportDestination(destination)")) - XCTAssertFalse(importMethod.contains("navigate(to: .apps)")) - XCTAssertFalse(exportMethod.contains("navigate(to: .apps)")) - } - - func testHomeMoreUsesTheCanonicalAppsPage() throws { - let source = try dashboardSource() - let openAppsMethod = try methodBody(named: "openAppsPage", in: source) - - XCTAssertTrue( - source.contains( - "HomeAIChoiceButton(title: \"More\", systemImage: \"plus\") {\n openAppsPage()" - )) - XCTAssertFalse(source.contains("private func appsPopupOverlay(")) - XCTAssertFalse(source.contains("@State private var isShowingAppsPopup")) - XCTAssertFalse(source.contains("\n AppsPage(")) - XCTAssertTrue(openAppsMethod.contains("appProvider.clearFilters()")) - XCTAssertTrue(openAppsMethod.contains("navigate(to: .apps)")) - } - - func testHomeConnectSheetsUseHomeScopedPresentation() throws { - let source = try dashboardSource() - let normalizedSource = normalizedWhitespace(source) - - XCTAssertTrue(source.contains("private var homeConnectSheetIsPresented: Bool")) - XCTAssertTrue(source.contains("private var legacySelectedCatalogApp: Binding<OmiApp?>")) - XCTAssertTrue(source.contains("private var legacySelectedImportConnector: Binding<ImportConnector?>")) - XCTAssertTrue(source.contains("private var legacySelectedExportDestination: Binding<MemoryExportDestination?>")) - XCTAssertTrue(source.contains("homeConnectSheetOverlay(\n contentWidth: proxy.size.width")) - XCTAssertTrue( - source.contains("let sheetSize = homeConnectSheetSize(panelWidth: panelWidth, panelHeight: panelHeight)")) - XCTAssertTrue(source.contains(".position(x: contentWidth / 2, y: panelTop + panelHeight / 2)")) - // omi-test-quality: source-inspection -- static contract: wiring for the contextual connector - // sheet, which is unreachable for the same reason — - // `selectedImportConnector` and its siblings are private `@State`. The click that runs it is - // behavioural in `ShellModalScrimDismissTests`. - XCTAssertTrue( - normalizedSource.contains("ShellModalScrim(onTap: dismissHomeConnectSheet)"), - "The dim behind the Home connect sheet must carry its dismiss action, or clicking outside the " - + "sheet stops closing it") - XCTAssertFalse(source.contains("homeConnectSheetHasKeyboardFocus")) - XCTAssertTrue(source.contains("private func dismissHomeConnectSheet()")) - } - - func testHomeOverlaysStopHitTestingWhenDismissStarts() throws { - let source = try dashboardSource() - let connectDismissMethod = try methodBody(named: "dismissHomeConnectSheet", in: source) - - XCTAssertTrue(source.contains("@State private var homeConnectSheetAcceptsInput = false")) - XCTAssertTrue(source.contains(".allowsHitTesting(homeConnectSheetAcceptsInput)")) - XCTAssertTrue(source.contains("if homeConnectSheetAcceptsInput")) - XCTAssertTrue(connectDismissMethod.contains("homeConnectSheetAcceptsInput = false")) - XCTAssertTrue(connectDismissMethod.contains("selectedImportConnector = nil")) - XCTAssertTrue(connectDismissMethod.contains("selectedExportDestination = nil")) - } - func testConnectorRowsUseStatusConnectionForConnectedState() throws { let destinationSheet = try source(named: "MemoryExportDestinationSheet.swift") let groupedSheet = try source(named: "AgentConnectPickerSheet.swift") @@ -332,57 +107,6 @@ final class DashboardCaptureStateTests: XCTestCase { } } - func testHomeOverlaysBehaveLikeModals() throws { - let dashboard = try dashboardSource() - let apps = try appsSource() - // The `dismissableSheet` modifiers are the shared presentation primitive - // both Home overlays and the pages mount; they live beside the pages that - // use them rather than inside any one of them. - let dismissableSheet = try source(named: "DismissableSheet.swift") - let escapeKeyHandler = try escapeKeyHandlerSource() - let normalizedDashboard = normalizedWhitespace(dashboard) - - // Esc must dismiss the topmost overlay. Custom ZStack overlays are not - // NSWindow sheets, so Esc comes from the shared catcher's window-scoped - // key monitor — onExitCommand never fires (the overlays are never - // focused) and hidden cancel-shortcut buttons get culled from dispatch. - XCTAssertTrue(escapeKeyHandler.contains("struct OverlayModalEscapeCatcher: View")) - XCTAssertTrue(escapeKeyHandler.contains("struct EscapeKeyHandler: NSViewRepresentable")) - XCTAssertTrue(escapeKeyHandler.contains("NSEvent.addLocalMonitorForEvents(matching: .keyDown)")) - XCTAssertTrue(escapeKeyHandler.contains("registration.window === window")) - XCTAssertTrue( - normalizedDashboard.contains("OverlayModalEscapeCatcher { dismissHomeConnectSheet()")) - XCTAssertFalse( - dashboard.contains(".onExitCommand"), - "Home overlays must not rely on onExitCommand — it requires focus the overlays never receive" - ) - XCTAssertTrue( - dismissableSheet.contains( - "OverlayModalEscapeCatcher {\n log(\"DISMISSABLE_SHEET: Escape pressed")) - - // While an overlay is up, the content underneath must be hidden from - // VoiceOver / Full Keyboard Access and the panel marked as modal. - XCTAssertTrue(dashboard.contains("private var isHomeModalPresented: Bool")) - XCTAssertTrue(dashboard.contains(".accessibilityHidden(isHomeModalPresented)")) - XCTAssertTrue(dashboard.contains(".accessibilityAddTraits(.isModal)")) - XCTAssertTrue(dismissableSheet.contains(".accessibilityHidden(isPresented)")) - XCTAssertTrue(dismissableSheet.contains(".accessibilityHidden(item != nil)")) - XCTAssertTrue(dismissableSheet.contains(".accessibilityAddTraits(.isModal)")) - - // The close control must be a real, labeled button — not a tap gesture. - XCTAssertTrue(apps.contains("var accessibilityLabel: String = \"Close\"")) - XCTAssertTrue(apps.contains(".accessibilityLabel(accessibilityLabel)")) - } - - private func dashboardSource() throws -> String { - let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let dashboardURL = - testsURL - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") - return try String(contentsOf: dashboardURL, encoding: .utf8) - } - private func captureLogicSource() throws -> String { let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() let logicURL = diff --git a/desktop/macos/Desktop/Tests/DashboardIntelligenceStoreTests.swift b/desktop/macos/Desktop/Tests/DashboardIntelligenceStoreTests.swift deleted file mode 100644 index 0276e5d6e7f..00000000000 --- a/desktop/macos/Desktop/Tests/DashboardIntelligenceStoreTests.swift +++ /dev/null @@ -1,1117 +0,0 @@ -import XCTest - -@testable import Omi_Computer - -@MainActor -final class DashboardIntelligenceStoreTests: XCTestCase { - override func setUp() async throws { - AccountCutoverControlManager.shared.resetForTesting() - AccountCutoverControlManager.shared.apply(.legacyDefault) - } - - override func tearDown() async throws { - AccountCutoverControlManager.shared.resetForTesting() - } - - func testEmptyProjectionIsAValidCalmState() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: []) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - await store.load() - - XCTAssertTrue(store.recommendations.isEmpty) - XCTAssertNil(store.error) - } - - func testConcurrentSameOwnerLoadsDedupeToASingleFetch() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: []) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - // Two same-owner loads launched together. load() must claim its dedup slot - // (loadingOwnerID) SYNCHRONOUSLY — before it yields at its first await — - // otherwise the second load runs on the MainActor before the first load's - // performLoad Task sets loadingOwnerID, sees it unset, and starts a second - // concurrent fetch. Because the claim is synchronous, the second load always - // observes it and awaits the in-flight load, so only one fetch happens. - let first = Task { await store.load() } - let second = Task { await store.load() } - await first.value - await second.value - - XCTAssertEqual( - api.projectionLoads, 1, "concurrent same-owner loads must dedupe to a single fetch") - } - - func testControlFailureUsesDashboardErrorWithoutLeakingBackendDetail() async { - let api = FakeDashboardIntelligenceClient() - api.controlError = APIError.httpError( - statusCode: 404, detail: "v1/candidates/control was not found") - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - await store.load() - - XCTAssertEqual(store.error, "Couldn't refresh the dashboard. Try again.") - XCTAssertTrue(store.recommendations.isEmpty) - XCTAssertTrue(store.goals.isEmpty) - } - - func testGoalsFailureUsesDashboardErrorWithoutMislabelingWhatMattersNow() async { - let api = FakeDashboardIntelligenceClient() - api.goalsError = APIError.httpError(statusCode: 503, detail: "goals service unavailable") - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - await store.load() - - XCTAssertEqual(store.error, "Omi's service is unavailable right now. Try again.") - XCTAssertTrue(store.goals.isEmpty) - } - - func testWhatMattersNowFailureSurvivesSuccessfulGoalsAndPendingFeedbackWarning() async { - let api = FakeDashboardIntelligenceClient() - api.projectionError = APIError.httpError(statusCode: 403, detail: "Device scope mismatch") - api.goals = [goal(id: "goal-1", status: .focused, rank: 0)] - api.failFeedback = true - let outbox = MemoryDashboardOutbox() - outbox.entries = [ - PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .later, - contextSnapshotHash: nil, - interventionId: "intervention-pending", - laterUntil: "2030-01-01T00:00:00Z", - reason: nil, - subjectId: "task-pending", - subjectKind: .task - ), - idempotencyKey: "feedback-pending", - accountGeneration: 7 - ) - ] - let store = DashboardIntelligenceStore(client: api, outboxStore: outbox) - - await store.load() - - XCTAssertEqual(store.error, "You don't have permission to do that.") - XCTAssertEqual(store.focusedGoals.map(\.goalId), ["goal-1"]) - XCTAssertTrue(store.recommendations.isEmpty) - XCTAssertEqual(outbox.entries.map(\.idempotencyKey), ["feedback-pending"]) - } - - func testContextProjectionRefreshesDashboardWithoutConsultingNotificationSettings() { - let defaults = UserDefaults.standard - let previousMaster = defaults.object(forKey: NotificationService.masterEnabledDefaultsKey) - let previousFrequency = defaults.object(forKey: NotificationService.frequencyDefaultsKey) - defer { - if let previousMaster { - defaults.set(previousMaster, forKey: NotificationService.masterEnabledDefaultsKey) - } else { - defaults.removeObject(forKey: NotificationService.masterEnabledDefaultsKey) - } - if let previousFrequency { - defaults.set(previousFrequency, forKey: NotificationService.frequencyDefaultsKey) - } else { - defaults.removeObject(forKey: NotificationService.frequencyDefaultsKey) - } - } - defaults.set(false, forKey: NotificationService.masterEnabledDefaultsKey) - defaults.set(0, forKey: NotificationService.frequencyDefaultsKey) - let store = DashboardIntelligenceStore( - client: FakeDashboardIntelligenceClient(), - outboxStore: MemoryDashboardOutbox(), - now: { Date(timeIntervalSince1970: 1_800_000_000) } - ) - - store.applyContextProjection(projection(items: [recommendation(id: "context-task")])) - - XCTAssertEqual(store.recommendations.map(\.subjectID), ["context-task"]) - } - - func testNotificationRecommendationRouteUsesExistingDashboardDestination() async { - let api = FakeDashboardIntelligenceClient() - let store = DashboardIntelligenceStore( - client: api, - outboxStore: MemoryDashboardOutbox(), - now: { Date(timeIntervalSince1970: 1_800_000_000) } - ) - store.applyContextProjection( - projection(items: [ - recommendation( - id: "artifact-1", - kind: .artifact, - destinationWorkstreamID: "workstream-existing" - ) - ])) - var openedDestination: DashboardRecommendationDestination? - store.setRecommendationActionHandler { recommendation in - openedDestination = recommendation.destination - return true - } - - let opened = await store.openRecommendation(id: "output-v1:dedupe-artifact-1") - - XCTAssertTrue(opened) - XCTAssertEqual( - openedDestination, - .thread(workstreamID: "workstream-existing", taskID: nil) - ) - } - - func testProjectionCapsAtThreeAndKeepsStableIdentityUntilOutputChanges() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: (1...4).map { recommendation(id: "task-\($0)") }) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - await store.load() - let firstIDs = store.recommendations.map(\.id) - - await store.load() - XCTAssertEqual(store.recommendations.map(\.id), firstIDs) - XCTAssertEqual(store.recommendations.count, 3) - - api.projection = projection( - outputVersion: "output-v2", - items: (1...3).map { recommendation(id: "task-\($0)", outputVersion: "output-v2") } - ) - await store.load() - - XCTAssertNotEqual(store.recommendations.map(\.id), firstIDs) - } - - func testExpiredProjectionAndExpiredCardsNeverRender() async { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let api = FakeDashboardIntelligenceClient() - api.projection = projection( - expiresAt: "2027-01-15T08:00:00Z", items: [recommendation(id: "task-1")]) - let store = DashboardIntelligenceStore( - client: api, - outboxStore: MemoryDashboardOutbox(), - now: { now } - ) - - await store.load() - - XCTAssertTrue(store.recommendations.isEmpty) - } - - func testProjectionDedupesAndSkipsExpiredOrUnroutableCardsBeforeCapping() { - let items = [ - recommendation(id: "expired", expiresAt: "2027-01-01T08:00:00Z"), - recommendation(id: "unroutable", kind: .artifact), - recommendation(id: "one", dedupeKey: "same"), - recommendation(id: "duplicate", dedupeKey: "same"), - recommendation(id: "two"), - recommendation(id: "three"), - recommendation(id: "four"), - ] - - let projected = DashboardIntelligenceStore.project( - projection(items: items), - now: Date(timeIntervalSince1970: 1_800_000_000) - ) - - XCTAssertEqual(projected.map(\.subjectID), ["one", "two", "three"]) - XCTAssertEqual(projected.count, 3) - } - - func testActionRoutingCoversEverySupportedSubjectKind() { - let cases: [(OmiAPI.RecommendationSubjectKind, String?, DashboardRecommendationDestination)] = [ - (.candidate, nil, .suggested(candidateID: "subject")), - (.task, nil, .task(taskID: "subject", workstreamID: nil)), - (.workstream, "thread-1", .thread(workstreamID: "thread-1", taskID: nil)), - (.artifact, "thread-1", .thread(workstreamID: "thread-1", taskID: nil)), - (.decision, "thread-1", .thread(workstreamID: "thread-1", taskID: nil)), - (.agent_open_loop, "thread-1", .thread(workstreamID: "thread-1", taskID: nil)), - ] - - for (kind, destinationWorkstreamID, expected) in cases { - let item = recommendation( - id: "subject", - kind: kind, - destinationWorkstreamID: destinationWorkstreamID - ) - let projected = DashboardIntelligenceStore.project( - projection(items: [item]), - now: Date(timeIntervalSince1970: 1_800_000_000) - ) - XCTAssertEqual(projected.first?.destination, expected) - } - } - - func testNavigationRequestWaitsForExactRenderedTargetBeforeConsuming() { - let navigation = TaskNavigationRequestStore() - navigation.request(candidate: candidate(id: "candidate-1")) - - XCTAssertNil(navigation.consumeIfAvailable(taskIDs: [], candidateIDs: [])) - XCTAssertEqual(navigation.peek(), .candidate("candidate-1")) - XCTAssertEqual( - navigation.consumeIfAvailable(taskIDs: [], candidateIDs: ["candidate-1"]), - .candidate("candidate-1") - ) - XCTAssertNil(navigation.peek()) - - navigation.request( - task: TaskActionItem( - id: "task-1", - description: "Exact task", - completed: false, - createdAt: Date(timeIntervalSince1970: 0) - )) - XCTAssertNil(navigation.consumeIfAvailable(taskIDs: ["other"], candidateIDs: [])) - XCTAssertEqual( - navigation.consumeIfAvailable(taskIDs: ["task-1"], candidateIDs: []), - .task("task-1") - ) - } - - @MainActor - func testNavigationRequestClearsOnRuntimeOwnerChange() async { - let navigation = TaskNavigationRequestStore() - navigation.request( - task: TaskActionItem( - id: "task-owner-a", - description: "Owner A task", - completed: false, - createdAt: Date(timeIntervalSince1970: 0) - )) - - NotificationCenter.default.post(name: .runtimeOwnerDidChange, object: nil) - await Task.yield() - - XCTAssertNil(navigation.peek()) - XCTAssertNil(navigation.pendingTask) - } - - func testExactNavigationTargetsAreHydratedBeforeDashboardAcceptsTheRoute() async { - let api = FakeDashboardIntelligenceClient() - api.exactCandidate = candidate(id: "candidate-101") - api.exactTask = TaskActionItem( - id: "old-task", - description: "Old but newly relevant task", - completed: false, - createdAt: Date(timeIntervalSince1970: 0) - ) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - let candidate = await store.candidateForNavigation(candidateID: "candidate-101") - let task = await store.taskForNavigation(taskID: "old-task") - - XCTAssertEqual(candidate?.candidateId, "candidate-101") - XCTAssertEqual(task?.id, "old-task") - } - - /// A recommendation can outlive its task: the projection is minted before the - /// task is cancelled/superseded/deleted. `TaskActionItem.isRetired` is the - /// projection that reads canonical lifecycle status, because detail responses - /// may omit legacy `deleted` entirely — so a raw `deleted` read (or, as here, - /// no retirement check at all) hands a retired task back as a live route. - func testRetiredTaskIsNotOpenedByNavigation() async { - for status in ["cancelled", "superseded"] { - let api = FakeDashboardIntelligenceClient() - api.exactTask = TaskActionItem( - id: "retired-task", - description: "Retired server-side after the recommendation was minted", - completed: false, - createdAt: Date(timeIntervalSince1970: 0), - deleted: nil, - taskStatus: status - ) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - let task = await store.taskForNavigation(taskID: "retired-task") - - XCTAssertNil(task, "a \(status) task must not open as a live navigation target") - XCTAssertEqual(store.error, "This task is no longer available.") - } - } - - /// The legacy marker still retires a task on backends that send it. - func testLegacyDeletedTaskIsNotOpenedByNavigation() async { - let api = FakeDashboardIntelligenceClient() - api.exactTask = TaskActionItem( - id: "deleted-task", - description: "Deleted server-side", - completed: false, - createdAt: Date(timeIntervalSince1970: 0), - deleted: true - ) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - let task = await store.taskForNavigation(taskID: "deleted-task") - - XCTAssertNil(task) - XCTAssertEqual(store.error, "This task is no longer available.") - } - - func testWriteSidecarModeDoesNotExposeDashboardIntelligence() async { - let api = FakeDashboardIntelligenceClient() - api.workflowMode = .write - api.projection = projection(items: [recommendation(id: "task-1")]) - api.goals = [goal(id: "goal-1", status: .focused, rank: 0)] - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - await store.load() - - XCTAssertTrue(store.recommendations.isEmpty) - XCTAssertTrue(store.goals.isEmpty) - XCTAssertEqual(api.projectionLoads, 0) - } - - func testCanonicalGoalsRemainAvailableOutsideIntelligenceCohort() async { - let api = FakeDashboardIntelligenceClient() - api.projectionError = APIError.httpError(statusCode: 404, detail: "Not found") - api.goals = [goal(id: "goal-1", status: .focused, rank: 0)] - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - await store.load() - - XCTAssertTrue(store.recommendations.isEmpty) - XCTAssertEqual(store.focusedGoals.map(\.goalId), ["goal-1"]) - XCTAssertNil(store.error) - } - - func testGoalFocusUsesExplicitReplacementAndKeepsHistory() async { - let api = FakeDashboardIntelligenceClient() - api.goals = [ - goal(id: "focused", status: .focused, rank: 0), - goal(id: "background", status: .background, rank: nil), - goal(id: "history", status: .achieved, rank: nil), - ] - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - await store.load() - - let focused = await store.focus(goalID: "background", replacing: "focused") - - XCTAssertTrue(focused) - XCTAssertEqual(api.focusRequests.last?.goalID, "background") - XCTAssertEqual(api.focusRequests.last?.replacementID, "focused") - XCTAssertEqual(store.endedGoals.map(\.goalId), ["history"]) - } - - func testGoalFocusConflictRequestsServerDrivenReplacement() async { - let api = FakeDashboardIntelligenceClient() - api.goals = [goal(id: "background", status: .background, rank: nil)] - api.focusError = APIError.httpError(statusCode: 409, detail: "focus set is full") - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - await store.load() - - let focused = await store.focus(goalID: "background", replacing: nil) - - XCTAssertFalse(focused) - XCTAssertEqual(store.focusReplacementGoalID, "background") - } - - func testGoalDetailUsesSingleAggregateRequest() async { - let api = FakeDashboardIntelligenceClient() - api.detail = OmiAPI.GoalDetailProjection( - activeThreads: [], - goal: goal(id: "goal-1", status: .focused, rank: 0), - progressEvents: [], - tasks: [] - ) - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - - await store.loadGoalDetail(goalID: "goal-1") - - XCTAssertEqual(api.detailLoads, 1) - XCTAssertEqual(store.selectedGoalDetail?.goal.goalId, "goal-1") - } - - func testGoalCreatePreservesQualitativeOutcomeFields() async { - let api = FakeDashboardIntelligenceClient() - api.goals = [goal(id: "goal-1", status: .background, rank: nil)] - let store = DashboardIntelligenceStore(client: api, outboxStore: MemoryDashboardOutbox()) - await store.load() - - let created = await store.createGoal( - title: "Investor pipeline", - desiredOutcome: "Build a repeatable investor pipeline", - whyItMatters: "Fund the next stage", - successCriteria: ["Ten qualified conversations"], - idempotencyKey: "goal-create-occurrence" - ) - - XCTAssertTrue(created) - XCTAssertEqual(api.createdGoal?.desiredOutcome, "Build a repeatable investor pipeline") - XCTAssertEqual(api.createdGoal?.successCriteria, ["Ten qualified conversations"]) - XCTAssertEqual(api.createdGoal?.generation, 7) - XCTAssertEqual(api.createdGoal?.idempotencyKey, "goal-create-occurrence") - } - - func testFeedbackFailurePersistsAndReplaysTheSameOccurrence() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: [recommendation(id: "task-1")]) - api.failFeedback = true - let outbox = MemoryDashboardOutbox() - let store = DashboardIntelligenceStore(client: api, outboxStore: outbox) - await store.load() - let card = try! XCTUnwrap(store.recommendations.first) - - await store.recordPrimaryAction(card) - - XCTAssertEqual(outbox.entries.count, 1) - XCTAssertEqual(api.feedbackKeys, ["wmn:intervention-task-1:do-now"]) - api.failFeedback = false - await store.load() - XCTAssertTrue(outbox.entries.isEmpty) - XCTAssertEqual( - api.feedbackKeys, - ["wmn:intervention-task-1:do-now", "wmn:intervention-task-1:do-now"] - ) - } - - func testFailedPendingLaterAndDismissFeedbackDoNotResurrectFromSuccessfulProjection() async { - let api = FakeDashboardIntelligenceClient() - api.failFeedback = true - api.projection = projection(items: [ - recommendation(id: "owner-a-later"), - recommendation(id: "owner-a-dismiss"), - recommendation(id: "owner-b-only"), - recommendation(id: "unrelated"), - ]) - let outbox = MemoryDashboardOutbox() - outbox.ownerID = "owner-a" - outbox.entries = [ - pendingFeedback(action: .later, key: "owner-a-later", subjectID: "owner-a-later"), - pendingFeedback(action: .dismiss, key: "owner-a-dismiss", subjectID: "owner-a-dismiss"), - ] - outbox.ownerID = "owner-b" - outbox.entries = [ - pendingFeedback(action: .dismiss, key: "owner-b-only", subjectID: "owner-b-only") - ] - outbox.ownerID = "owner-a" - let store = DashboardIntelligenceStore(client: api, outboxStore: outbox) - - await store.load() - - XCTAssertEqual(store.recommendations.map(\.subjectID), ["owner-b-only", "unrelated"]) - XCTAssertEqual(api.feedbackKeys, ["owner-a-later", "owner-a-dismiss"]) - XCTAssertEqual(outbox.load(ownerID: "owner-a").count, 2) - XCTAssertEqual(outbox.load(ownerID: "owner-b").map(\.idempotencyKey), ["owner-b-only"]) - XCTAssertEqual(store.error, "Saved feedback will retry automatically.") - } - - func testLoadPassesHeaderBoundClientDeviceID() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: [recommendation(id: "task-1")]) - let store = DashboardIntelligenceStore( - client: api, - outboxStore: MemoryDashboardOutbox(), - deviceIDProvider: { "macos_deadbeef" } - ) - - await store.load() - - XCTAssertEqual(api.lastDeviceID, "macos_deadbeef") - XCTAssertEqual(store.recommendations.map(\.subjectID), ["task-1"]) - } - - func testPresentationFeedbackAndDoNowEmitBoundedAttributionWithoutFalseOutcome() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: [recommendation(id: "task-1")]) - var events: [TaskIntelligenceAttributionEvent] = [] - let store = DashboardIntelligenceStore( - client: api, - outboxStore: MemoryDashboardOutbox(), - deviceIDProvider: { "device-1" }, - reportAttribution: { events.append($0) } - ) - - await store.load() - XCTAssertEqual(events.map(\.eventType), [.interventionPresented]) - XCTAssertEqual(events[0].interventionID, "intervention-task-1") - XCTAssertEqual(events[0].surface, .whatMattersNow) - XCTAssertNil(events[0].analyticsProperties["content"]) - - let card = try! XCTUnwrap(store.recommendations.first) - await store.later(card) - - XCTAssertEqual(events.map(\.eventType), [.interventionPresented, .feedbackRecorded]) - XCTAssertEqual(events[1].feedbackAction, "later") - XCTAssertEqual(events[1].subjectID, "task-1") - - api.projection = projection(items: [recommendation(id: "task-2")]) - await store.load() - let doNowCard = try! XCTUnwrap(store.recommendations.first) - await store.recordPrimaryAction(doNowCard) - - XCTAssertEqual( - events.map(\.eventType), - [.interventionPresented, .feedbackRecorded, .interventionPresented, .feedbackRecorded] - ) - XCTAssertTrue(api.outcomeRequests.isEmpty) - XCTAssertTrue(api.outcomeKeys.isEmpty) - XCTAssertNil(events.last?.outcomeCode) - XCTAssertNil(events.last?.analyticsProperties["headline"]) - } - - func testAccountSwitchSupersedesDelayedProjectionAndKeepsNewOwnerGoalMix() async { - let api = FakeDashboardIntelligenceClient() - api.projection = projection( - outputVersion: "owner-a-output", - items: [recommendation(id: "owner-a-task", outputVersion: "owner-a-output")] - ) - api.goals = [goal(id: "owner-a-goal", status: .focused, rank: 0)] - api.projectionSuspensionsRemaining = 1 - let outbox = MemoryDashboardOutbox() - outbox.ownerID = "owner-a" - let store = DashboardIntelligenceStore(client: api, outboxStore: outbox) - - let ownerALoad = Task { await store.load() } - while api.projectionRelease == nil { await Task.yield() } - let ownerARelease = api.projectionRelease - - outbox.ownerID = "owner-b" - api.projection = projection( - outputVersion: "owner-b-output", - items: [recommendation(id: "owner-b-task", outputVersion: "owner-b-output")] - ) - api.goals = [goal(id: "owner-b-goal", status: .focused, rank: 0)] - await store.load() - - XCTAssertEqual(store.recommendations.map(\.subjectID), ["owner-b-task"]) - XCTAssertEqual(store.goals.map(\.goalId), ["owner-b-goal"]) - - ownerARelease?.resume() - await ownerALoad.value - - XCTAssertEqual(store.recommendations.map(\.subjectID), ["owner-b-task"]) - XCTAssertEqual(store.goals.map(\.goalId), ["owner-b-goal"]) - XCTAssertNil(store.error) - } - - func testAccountSwitchDuringDelayedFeedbackCannotRemoveOrAttributeNewOwnerRecommendation() - async throws - { - let api = FakeDashboardIntelligenceClient() - api.projection = projection( - outputVersion: "owner-a-output", - items: [recommendation(id: "owner-a-task", outputVersion: "owner-a-output")] - ) - let outbox = MemoryDashboardOutbox() - outbox.ownerID = "owner-a" - var events: [TaskIntelligenceAttributionEvent] = [] - let store = DashboardIntelligenceStore( - client: api, - outboxStore: outbox, - reportAttribution: { events.append($0) } - ) - await store.load() - let ownerACard = try XCTUnwrap(store.recommendations.first) - api.feedbackSuspensionsRemaining = 1 - - let ownerAFeedback = Task { await store.later(ownerACard) } - while api.feedbackRelease == nil { await Task.yield() } - let ownerARelease = api.feedbackRelease - - outbox.ownerID = "owner-b" - api.projection = projection( - outputVersion: "owner-b-output", - items: [recommendation(id: "owner-b-task", outputVersion: "owner-b-output")] - ) - api.goals = [goal(id: "owner-b-goal", status: .focused, rank: 0)] - await store.load() - - ownerARelease?.resume() - await ownerAFeedback.value - - XCTAssertEqual(store.recommendations.map(\.subjectID), ["owner-b-task"]) - XCTAssertEqual(store.goals.map(\.goalId), ["owner-b-goal"]) - XCTAssertTrue(outbox.load(ownerID: "owner-b").isEmpty) - XCTAssertEqual(outbox.load(ownerID: "owner-a").count, 1) - XCTAssertTrue( - outbox.load(ownerID: "owner-a")[0].idempotencyKey - .hasPrefix("wmn:intervention-owner-a-task:later:") - ) - XCTAssertFalse(events.contains { $0.eventType == .feedbackRecorded }) - XCTAssertNil(store.error) - } - - func testRecommendationActionStartedAfterOwnerSwitchCannotQueuePreviousOwnerCard() async throws { - let api = FakeDashboardIntelligenceClient() - api.projection = projection(items: [recommendation(id: "owner-a-task")]) - let outbox = MemoryDashboardOutbox() - outbox.ownerID = "owner-a" - let store = DashboardIntelligenceStore(client: api, outboxStore: outbox) - await store.load() - let ownerACard = try XCTUnwrap(store.recommendations.first) - - outbox.ownerID = "owner-b" - await store.later(ownerACard) - - XCTAssertTrue(store.recommendations.isEmpty) - XCTAssertTrue(outbox.load(ownerID: "owner-a").isEmpty) - XCTAssertTrue(outbox.load(ownerID: "owner-b").isEmpty) - XCTAssertTrue(api.feedbackKeys.isEmpty) - XCTAssertNil(store.error) - } - - func testDashboardDoesNotPersistOrRewriteTaskOrder() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent() - let storeSource = try String( - contentsOf: root.appendingPathComponent( - "Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift"), - encoding: .utf8 - ) - XCTAssertFalse(storeSource.contains("TaskPrioritizationService")) - XCTAssertFalse(storeSource.contains("sortOrder")) - XCTAssertFalse(storeSource.contains("relevanceScore")) - XCTAssertFalse(storeSource.contains("UserDefaults.standard.set(recommendations")) - let tasksSource = try String( - contentsOf: root.appendingPathComponent("Sources/MainWindow/Pages/TasksPage.swift"), - encoding: .utf8 - ) - XCTAssertTrue(tasksSource.contains("func revealTaskForNavigation")) - XCTAssertTrue(tasksSource.contains("searchText = \"\"")) - XCTAssertTrue(tasksSource.contains("selectedTags = [.todo]")) - } - - private func projection( - outputVersion: String = "output-v1", - expiresAt: String = "2027-02-15T08:00:00Z", - items: [OmiAPI.Recommendation] - ) -> OmiAPI.WhatMattersNowProjection { - OmiAPI.WhatMattersNowProjection( - evaluationId: "evaluation-1", - expiresAt: expiresAt, - generatedAt: "2027-01-15T08:00:00Z", - materialVersion: "material-1", - outputVersion: outputVersion, - recommendations: items, - schemaVersion: 1 - ) - } - - private func pendingFeedback( - action: OmiAPI.TaskIntelligenceFeedbackAction, - key: String, - subjectID: String - ) -> PendingDashboardFeedback { - PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: action, - contextSnapshotHash: nil, - interventionId: "intervention-\(subjectID)", - laterUntil: action == .later ? "2030-01-01T00:00:00Z" : nil, - reason: action == .dismiss ? .not_useful : nil, - subjectId: subjectID, - subjectKind: .task - ), - idempotencyKey: key, - accountGeneration: 7 - ) - } - - private func recommendation( - id: String, - kind: OmiAPI.RecommendationSubjectKind = .task, - outputVersion: String = "output-v1", - destinationWorkstreamID: String? = nil, - expiresAt: String = "2027-02-15T08:00:00Z", - dedupeKey: String? = nil - ) -> OmiAPI.Recommendation { - OmiAPI.Recommendation( - alternativeAction: nil, - dedupeKey: dedupeKey ?? "dedupe-\(id)", - destinationTaskId: kind == .task ? id : nil, - destinationWorkstreamId: destinationWorkstreamID, - evidencePreview: "Linked evidence", - evidenceRefs: [], - expiresAt: expiresAt, - feedbackSubjectId: id, - feedbackSubjectKind: kind == .candidate ? .candidate : .task, - goalOrWorkstreamLabel: "Launch", - headline: "Handle \(id)", - interventionId: "intervention-\(id)", - outputVersion: outputVersion, - recommendedAction: "Open", - subjectId: id, - subjectKind: kind, - whyNow: "It changed materially." - ) - } - - private func goal(id: String, status: OmiAPI.GoalStatus, rank: Int?) -> OmiAPI.GoalResponse { - OmiAPI.GoalResponse( - advice: nil, - createdAt: "2027-01-01T08:00:00Z", - currentValue: 1, - desiredOutcome: "Reach the outcome", - endedAt: status == .achieved ? "2027-01-10T08:00:00Z" : nil, - focusRank: rank, - goalId: id, - goalType: "numeric", - horizonAt: nil, - id: id, - isActive: status != .achieved && status != .abandoned, - latestProgressSequence: nil, - maxValue: 10, - metric: nil, - minValue: 0, - source: .user, - status: status, - successCriteria: ["Done"], - targetValue: 10, - title: "Goal \(id)", - unit: nil, - updatedAt: "2027-01-10T08:00:00Z", - whyItMatters: "Important" - ) - } - - private func candidate(id: String) -> OmiAPI.CandidateRecord { - OmiAPI.CandidateRecord( - accountGeneration: 7, - candidateId: id, - captureConfidence: 0.9, - createdAt: "2027-01-15T08:00:00Z", - evidenceRefs: [], - goalId: nil, - idempotencyKey: "capture-\(id)", - ownershipConfidence: 0.9, - proposedAction: .create, - resolutionReason: nil, - resolvedAt: nil, - resultTaskId: nil, - resultWorkstreamId: nil, - sourceSurface: "conversation", - status: .pending, - subjectKind: .task, - taskChange: .create( - OmiAPI.TaskCreatePayload( - description_: "Review exact candidate", - dueAt: nil, - dueConfidence: nil, - owner: .user, - priority: .medium, - recurrenceParentId: nil, - recurrenceRule: nil - )), - taskId: nil, - workstreamId: nil, - workstreamProposal: nil - ) - } -} - -private final class MemoryDashboardOutbox: DashboardFeedbackOutboxPersisting { - var ownerID = "test-owner" - private var entriesByOwner: [String: [PendingDashboardFeedback]] = [:] - - var entries: [PendingDashboardFeedback] { - get { load(ownerID: ownerID) } - set { save(newValue, ownerID: ownerID) } - } - - func currentOwnerID() -> String { ownerID } - func load(ownerID: String) -> [PendingDashboardFeedback] { entriesByOwner[ownerID] ?? [] } - func save(_ entries: [PendingDashboardFeedback], ownerID: String) { - entriesByOwner[ownerID] = entries - } -} - -@MainActor -final class DashboardFeedbackOutboxOwnerIsolationTests: XCTestCase { - override func setUp() async throws { - AccountCutoverControlManager.shared.resetForTesting() - AccountCutoverControlManager.shared.apply(.legacyDefault) - } - - override func tearDown() async throws { - AccountCutoverControlManager.shared.resetForTesting() - } - - func testDefaultOwnerTracksAuthenticationChanges() { - let suite = "DashboardFeedbackOutboxOwnerIsolationTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - let outbox = DashboardFeedbackOutboxDefaults(defaults: defaults) - let entry = PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .dismiss, - contextSnapshotHash: nil, - interventionId: nil, - laterUntil: nil, - reason: .not_useful, - subjectId: "task-1", - subjectKind: .task - ), - idempotencyKey: "feedback-1", - accountGeneration: 7 - ) - defaults.set("owner-a", forKey: "auth_userId") - outbox.save([entry], ownerID: outbox.currentOwnerID()) - defaults.set("owner-b", forKey: "auth_userId") - XCTAssertTrue(outbox.load(ownerID: outbox.currentOwnerID()).isEmpty) - defaults.set("owner-a", forKey: "auth_userId") - XCTAssertEqual( - outbox.load(ownerID: outbox.currentOwnerID()).first?.idempotencyKey, "feedback-1") - } - - func testAccountSwitchDuringFeedbackDoesNotOverwriteNewOwnerQueue() async { - let suite = "DashboardFeedbackOutboxOwnerIsolationTests.inflight.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - defaults.set("owner-a", forKey: "auth_userId") - let outbox = DashboardFeedbackOutboxDefaults(defaults: defaults) - let client = FakeDashboardIntelligenceClient() - client.projection = Self.projection(id: "recommendation-1") - client.feedbackSuspensionsRemaining = 1 - let store = DashboardIntelligenceStore(client: client, outboxStore: outbox) - await store.load() - let recommendation = store.recommendations[0] - let requestTask = Task { await store.later(recommendation) } - while client.feedbackRelease == nil { await Task.yield() } - defaults.set("owner-b", forKey: "auth_userId") - let ownerBEntry = PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .dismiss, - contextSnapshotHash: nil, - interventionId: nil, - laterUntil: nil, - reason: .not_useful, - subjectId: "task-b", - subjectKind: .task - ), - idempotencyKey: "owner-b-feedback", - accountGeneration: 7 - ) - outbox.save([ownerBEntry], ownerID: "owner-b") - client.feedbackRelease?.resume() - await requestTask.value - - XCTAssertEqual(outbox.load(ownerID: "owner-a").count, 1) - XCTAssertTrue( - outbox.load(ownerID: "owner-a")[0].idempotencyKey - .hasPrefix("wmn:intervention-recommendation-1:later:") - ) - XCTAssertEqual(outbox.load(ownerID: "owner-b").map(\.idempotencyKey), ["owner-b-feedback"]) - } - - func testRetryMergesConcurrentSameOwnerEnqueue() async { - let suite = "DashboardFeedbackOutboxOwnerIsolationTests.retry.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - defaults.set("owner-a", forKey: "auth_userId") - let outbox = DashboardFeedbackOutboxDefaults(defaults: defaults) - let retryEntry = PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .later, - contextSnapshotHash: nil, - interventionId: "intervention-retry", - laterUntil: "2030-01-01T00:00:00Z", - reason: nil, - subjectId: "task-retry", - subjectKind: .task - ), - idempotencyKey: "retry-feedback", - accountGeneration: 7 - ) - outbox.save([retryEntry], ownerID: "owner-a") - let client = FakeDashboardIntelligenceClient() - client.feedbackSuspensionsRemaining = 1 - let store = DashboardIntelligenceStore(client: client, outboxStore: outbox) - store.applyContextProjection(Self.projection(id: "new-recommendation")) - let recommendation = store.recommendations[0] - let loadTask = Task { await store.load() } - while client.feedbackRelease == nil { await Task.yield() } - client.failFeedback = true - await store.later(recommendation) - client.failFeedback = false - client.feedbackRelease?.resume() - await loadTask.value - - let remaining = outbox.load(ownerID: "owner-a") - XCTAssertEqual(remaining.count, 1) - XCTAssertTrue( - remaining[0].idempotencyKey.hasPrefix("wmn:intervention-new-recommendation:later:")) - } - - private static func projection(id: String) -> OmiAPI.WhatMattersNowProjection { - OmiAPI.WhatMattersNowProjection( - evaluationId: "evaluation-\(id)", - expiresAt: "2030-01-01T00:00:00Z", - generatedAt: "2027-01-15T08:00:00Z", - materialVersion: "material-1", - outputVersion: "output-1", - recommendations: [ - OmiAPI.Recommendation( - alternativeAction: nil, - dedupeKey: "dedupe-\(id)", - destinationTaskId: id, - destinationWorkstreamId: nil, - evidencePreview: "Evidence", - evidenceRefs: [], - expiresAt: "2030-01-01T00:00:00Z", - feedbackSubjectId: id, - feedbackSubjectKind: .task, - goalOrWorkstreamLabel: nil, - headline: "Continue task", - interventionId: "intervention-\(id)", - outputVersion: "output-1", - recommendedAction: "Continue", - subjectId: id, - subjectKind: .task, - whyNow: "Ready" - ) - ], - schemaVersion: 1 - ) - } -} - -private final class FakeDashboardIntelligenceClient: DashboardIntelligenceClient { - nonisolated(unsafe) var workflowMode = OmiAPI.TaskWorkflowMode.read - nonisolated(unsafe) var projection: OmiAPI.WhatMattersNowProjection - nonisolated(unsafe) var goals: [OmiAPI.GoalResponse] = [] - nonisolated(unsafe) var detail: OmiAPI.GoalDetailProjection? - nonisolated(unsafe) var projectionLoads = 0 - nonisolated(unsafe) var projectionError: Error? - nonisolated(unsafe) var projectionSuspensionsRemaining = 0 - nonisolated(unsafe) var projectionRelease: CheckedContinuation<Void, Never>? - nonisolated(unsafe) var controlError: Error? - nonisolated(unsafe) var goalsError: Error? - nonisolated(unsafe) var detailLoads = 0 - nonisolated(unsafe) var focusRequests: [(goalID: String, replacementID: String?)] = [] - nonisolated(unsafe) var focusError: Error? - nonisolated(unsafe) var failFeedback = false - nonisolated(unsafe) var feedbackKeys: [String] = [] - nonisolated(unsafe) var feedbackSuspensionsRemaining = 0 - nonisolated(unsafe) var feedbackRelease: CheckedContinuation<Void, Never>? - nonisolated(unsafe) var outcomeRequests: [OmiAPI.OutcomeCreate] = [] - nonisolated(unsafe) var outcomeKeys: [String] = [] - nonisolated(unsafe) var failOutcome = false - nonisolated(unsafe) var lastDeviceID: String? - nonisolated(unsafe) var createdGoal: - (desiredOutcome: String, successCriteria: [String], generation: Int, idempotencyKey: String)? - nonisolated(unsafe) var exactCandidate: OmiAPI.CandidateRecord? - nonisolated(unsafe) var exactTask: TaskActionItem? - - init() { - projection = OmiAPI.WhatMattersNowProjection( - evaluationId: "evaluation-empty", - expiresAt: "2027-02-15T08:00:00Z", - generatedAt: "2027-01-15T08:00:00Z", - materialVersion: "material-empty", - outputVersion: "output-empty", - recommendations: [], - schemaVersion: 1 - ) - } - - func getCandidateWorkflowControl() async throws -> OmiAPI.TaskWorkflowControl { - if let controlError { throw controlError } - return OmiAPI.TaskWorkflowControl(accountGeneration: 7, workflowMode: workflowMode) - } - - func getWhatMattersNow(deviceID: String?) async throws -> OmiAPI.WhatMattersNowProjection { - projectionLoads += 1 - lastDeviceID = deviceID - let result = projection - let resultError = projectionError - if projectionSuspensionsRemaining > 0 { - projectionSuspensionsRemaining -= 1 - await withCheckedContinuation { projectionRelease = $0 } - projectionRelease = nil - } - if let resultError { throw resultError } - return result - } - - func getCanonicalGoals(includeEnded: Bool) async throws -> [OmiAPI.GoalResponse] { - if let goalsError { throw goalsError } - return goals - } - - func getCanonicalGoalDetail(goalID: String) async throws -> OmiAPI.GoalDetailProjection { - detailLoads += 1 - guard let detail else { throw FakeError.missing } - return detail - } - - func getCanonicalCandidate(candidateID: String) async throws -> OmiAPI.CandidateRecord { - guard let exactCandidate else { throw FakeError.missing } - return exactCandidate - } - - func getActionItem(id: String) async throws -> TaskActionItem { - guard let exactTask else { throw FakeError.missing } - return exactTask - } - - func createCanonicalGoal( - title: String, desiredOutcome: String, whyItMatters: String?, successCriteria: [String], - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - createdGoal = (desiredOutcome, successCriteria, accountGeneration, idempotencyKey) - return goals.first! - } - - func recordTaskFeedback( - _ request: OmiAPI.FeedbackCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.FeedbackRecord { - feedbackKeys.append(idempotencyKey) - if feedbackSuspensionsRemaining > 0 { - feedbackSuspensionsRemaining -= 1 - await withCheckedContinuation { feedbackRelease = $0 } - feedbackRelease = nil - } - if failFeedback { throw FakeError.missing } - return OmiAPI.FeedbackRecord( - action: request.action, - attributionChainId: "attribution", - contextSnapshotHash: nil, - createdAt: "2027-01-15T08:00:00Z", - dedupeKey: "dedupe", - feedbackId: "feedback", - interventionId: request.interventionId, - laterUntil: request.laterUntil, - proposedCompletion: false, - proposedCompletionCandidateId: nil, - reason: request.reason, - subjectId: request.subjectId, - subjectKind: request.subjectKind - ) - } - - func createTaskOutcome( - _ request: OmiAPI.OutcomeCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.OutcomeRecord { - outcomeRequests.append(request) - outcomeKeys.append(idempotencyKey) - if failOutcome { throw FakeError.missing } - return OmiAPI.OutcomeRecord( - attributionChainId: request.attributionChainId, - occurredAt: "2027-01-15T08:00:00Z", - outcomeCode: request.outcomeCode, - outcomeId: "outcome-\(idempotencyKey)", - subjectId: request.subjectId, - subjectKind: request.subjectKind - ) - } - - func focusCanonicalGoal( - goalID: String, replacementGoalID: String?, focusRank: Int?, accountGeneration: Int, - idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - focusRequests.append((goalID, replacementGoalID)) - if let focusError { throw focusError } - return goals.first(where: { $0.goalId == goalID })! - } - - func unfocusCanonicalGoal( - goalID: String, accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - goals.first(where: { $0.goalId == goalID })! - } - - func transitionCanonicalGoal( - goalID: String, status: OmiAPI.GoalStatus, relationshipDisposition: String, - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - goals.first(where: { $0.goalId == goalID })! - } - - enum FakeError: Error { case missing } -} diff --git a/desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift b/desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift new file mode 100644 index 00000000000..9c7c2677cea --- /dev/null +++ b/desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift @@ -0,0 +1,139 @@ +import XCTest + +@testable import Omi_Computer + +/// The lanes behind the voice `get_tasks` tool, the About-user card, and the +/// assistant's task grounding. +/// +/// They are the only read of the user's tasks that is not the Tasks page, and +/// they used to answer a different question than the page did. Two filters did +/// it: a seven-day recency window on both the overdue and the undated bucket, +/// and a source filter that dropped every AI-capture row. On a real account — +/// a month-old backlog, captured from conversations before capture became +/// suggestion-only — all three buckets computed to zero while the Tasks page +/// showed thirty tasks, and the assistant answered "you don't have any tasks +/// overdue or due today" to someone looking at their list. +@MainActor +final class DashboardTaskLaneReachTests: XCTestCase { + private var fixture: RewindStorageTestIsolation.Fixture? + private var previousOwnerID: String? + private var previousAuth: RewindStorageTestIsolation.AuthSnapshot? + + override func setUp() async throws { + let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "dashboard-lane-reach") + self.fixture = fixture + previousAuth = RewindStorageTestIsolation.captureAuthSnapshot() + previousOwnerID = RuntimeOwnerIdentity.currentOwnerId() + await transitionOwner(to: fixture.testUserId) + RewindStorageTestIsolation.signInForTests(userId: fixture.testUserId) + TasksStore.shared.resetSessionState() + } + + override func tearDown() async throws { + TasksStore.shared.resetSessionState() + if let previousAuth { RewindStorageTestIsolation.restoreAuthSnapshot(previousAuth) } + await transitionOwner(to: previousOwnerID) + await RewindStorageTestIsolation.tearDown(userDir: fixture?.userDir) + fixture = nil + } + + /// Every row the Tasks page would show under "Today" and "No Deadline" has to + /// reach the lanes the assistant reads, whatever its age and whoever captured + /// it. The four rows below are the four ways the old filters lost one. + func testTheAssistantsLanesReachEveryTaskTheTasksPageShows() async throws { + let now = Date() + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + + try await ActionItemStorage.shared.syncTaskActionItems( + [ + item( + id: "overdue-by-a-month", + description: "Visit parents", + dueAt: calendar.date(byAdding: .day, value: -34, to: startOfToday), + createdAt: now.addingTimeInterval(-35 * 86_400), + source: "manual"), + item( + id: "overdue-and-captured", + description: "Apply to the matcha and mahjong event", + dueAt: calendar.date(byAdding: .day, value: -34, to: startOfToday), + createdAt: now.addingTimeInterval(-35 * 86_400), + source: "conversation"), + item( + id: "due-today", + description: "Finish the demo", + dueAt: calendar.date(byAdding: .hour, value: 9, to: startOfToday), + createdAt: now, + source: "manual"), + item( + id: "undated-and-old", + description: "Keep fishing for a stronger hook", + dueAt: nil, + createdAt: now.addingTimeInterval(-30 * 86_400), + source: "legacy"), + ], + authorization: .unrestricted) + + await TasksStore.shared.loadDashboardTasks() + + let overdue = Set(TasksStore.shared.overdueTasks.map(\.id)) + XCTAssertTrue( + overdue.contains("overdue-by-a-month"), + "a task overdue by more than a week is still on the user's list — the page has no lower bound") + XCTAssertTrue( + overdue.contains("overdue-and-captured"), + "capture is suggestion-only now (INV-TASK-2), so a row in action_items is already the user's") + XCTAssertEqual( + TasksStore.shared.todaysTasks.map(\.id), ["due-today"], + "a task due today belongs to today's bucket and nowhere else") + XCTAssertEqual( + TasksStore.shared.tasksWithoutDueDate.map(\.id), ["undated-and-old"], + "an undated task does not age out of the list it has always been sitting in") + } + + /// The spoken answer is assembled from the three buckets, so an empty answer + /// has to mean an empty list. + func testAnEmptyAnswerMeansAnEmptyList() async throws { + await TasksStore.shared.loadDashboardTasks() + + XCTAssertTrue(TasksStore.shared.overdueTasks.isEmpty) + XCTAssertTrue(TasksStore.shared.todaysTasks.isEmpty) + XCTAssertTrue(TasksStore.shared.tasksWithoutDueDate.isEmpty) + } + + private func item( + id: String, + description: String, + dueAt: Date?, + createdAt: Date, + source: String + ) -> TaskActionItem { + TaskActionItem( + id: id, + description: description, + completed: false, + createdAt: createdAt, + dueAt: dueAt, + source: source) + } + + private func transitionOwner(to ownerID: String?) async { + do { + _ = try await RuntimeOwnerIdentity.performEffectiveOwnerTransition( + plannedNextOwner: { _, _ in ownerID }, + quiesceVoice: { _, _ in }, + retargetLocalStorage: { _, _ in }, + ownerDidChange: {}, + { defaults in + defaults.removeObject(forKey: .automationOwnerOverride) + if let ownerID { + defaults.set(ownerID, forKey: .authUserId) + } else { + defaults.removeObject(forKey: .authUserId) + } + }) + } catch { + XCTFail("owner transition failed: \(error)") + } + } +} diff --git a/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift b/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift index c526fbab43c..948962bf434 100644 --- a/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift +++ b/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift @@ -73,28 +73,40 @@ final class DesktopChatDriftGuardTests: XCTestCase { } XCTAssertEqual(ChatTranscriptLayout.topAdjustment(at: 0, in: messages), 0) - // assistant → user starts a new exchange and takes the full gap. - XCTAssertEqual(gap(1), ChatTranscriptLayout.regularRowSpacing) + // assistant → user: the assistant row already reserves its own 28 pt band, + // which is the separation. Adding the exchange gap on top charged twice. + XCTAssertEqual(gap(1), ChatTranscriptLayout.afterMetadataBandRowSpacing) XCTAssertEqual(gap(2), ChatTranscriptLayout.consecutiveUserRowSpacing) // user → assistant is one exchange, so it is the tight gap. XCTAssertEqual(gap(3), ChatTranscriptLayout.replySpacing) - XCTAssertEqual(gap(4), ChatTranscriptLayout.regularRowSpacing) + XCTAssertEqual(gap(4), ChatTranscriptLayout.afterMetadataBandRowSpacing) XCTAssertLessThan( ChatTranscriptLayout.replySpacing, ChatTranscriptLayout.regularRowSpacing, "a reply must bind to its question more tightly than to the next exchange") + XCTAssertLessThan( + ChatTranscriptLayout.replySpacing, + ChatBubbleMetadataControlMetrics.bandHeight + + ChatTranscriptLayout.afterMetadataBandRowSpacing, + "the exchange boundary is still the widest gap once the band is counted") } - /// The gap after an assistant row is also the room its hover-revealed metadata - /// band draws into — that band is zero-height at rest, so the space has to come - /// from somewhere, and it comes from here. - func testTheGapAfterAnAssistantRowStaysWideEnoughForItsHoverBand() { - let messages = [ - ChatMessage(id: "a0", text: "Answer", sender: .ai), - ChatMessage(id: "a1", text: "Also this", sender: .ai), - ] + /// The band is real, reserved height under the row, so the *stack* must not + /// also pay for it — that double charge is what left two one-line answers + /// roughly 100 device pixels apart. + func testTheGapAfterARowThatReservesItsBandIsNotChargedTwice() { + let banded = ChatMessage(id: "a0", text: "Answer", sender: .ai) + let next = ChatMessage(id: "a1", text: "Also this", sender: .ai) + XCTAssertNotEqual(ChatBubbleMetadataBand.of(banded), .hidden) XCTAssertEqual( - ChatTranscriptLayout.spacing(from: messages[0], to: messages[1]), + ChatTranscriptLayout.spacing(from: banded, to: next), + ChatTranscriptLayout.afterMetadataBandRowSpacing) + + // A row with no band of its own still takes the ordinary exchange gap. + let streaming = ChatMessage(id: "a2", text: "Thinking", sender: .ai, isStreaming: true) + XCTAssertEqual(ChatBubbleMetadataBand.of(streaming), .hidden) + XCTAssertEqual( + ChatTranscriptLayout.spacing(from: streaming, to: next), ChatTranscriptLayout.regularRowSpacing) } @@ -178,35 +190,33 @@ final class DesktopChatDriftGuardTests: XCTestCase { XCTAssertTrue(messagesSource.contains("ChatScrollLiveEdge.canResumeFollowing")) } - func testChatFirstShellUsesModernTopNavigation() throws { + func testEveryMainWindowChatSurfaceSharesOneRendererAndOneContext() throws { let shellSource = try sourceFile("MainWindow/ChatFirst/ChatFirstShell.swift") let queryHomeSource = try sourceFile("MainWindow/QueryShell/QueryShellHome.swift") let answerThreadSource = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") - let dashboardSource = try sourceFile("MainWindow/Pages/DashboardPage.swift") + let bubbleSource = try sourceFile("MainWindow/Components/ChatBubble.swift") + let taskPanelSource = try sourceFile("MainWindow/Components/TaskChatPanel.swift") - // omi-test-quality: source-inspection -- static contract: the Chat-first shell must share the modern - // top-navigation and the single QueryShellHome chat surface, while rich-block capability and - // visible-transcript lifecycle remain threaded through the shared answer view. + // omi-test-quality: source-inspection -- static contract: there is one shell, one chat + // destination, and one content-block context threaded through every host. Behavioural coverage + // of what the blocks then do lives in `OneChatShellRichBlockTests`. XCTAssertTrue(shellSource.contains("DesktopTopBar(")) - // The shell keeps the modern chat surface in one shared destination so - // the legacy Dashboard alias cannot drift into a second implementation. XCTAssertTrue(shellSource.contains("case .chat, .more(.dashboard):")) XCTAssertTrue(shellSource.contains("private var chatDestination: some View")) - XCTAssertFalse(shellSource.contains("case .chat:\n DashboardPage(")) - XCTAssertTrue(shellSource.contains("forceModernPresentation: true")) XCTAssertTrue(shellSource.contains("chatFirstRichBlockContext: richBlockContext")) - XCTAssertTrue(queryHomeSource.contains("forceModernPresentation")) - XCTAssertTrue(queryHomeSource.contains("chatFirstRichBlockContext: chatFirstRichBlockContext")) - XCTAssertTrue(answerThreadSource.contains("chatFirstRichBlockContext: chatFirstRichBlockContext")) XCTAssertTrue(answerThreadSource.contains("chatTranscriptFirstPageDidLoad()")) XCTAssertTrue(answerThreadSource.contains("chatTranscriptDidDisappear()")) XCTAssertFalse(shellSource.contains("ChatFirstSidebar(")) XCTAssertFalse(shellSource.contains("\n ChatPage(")) - XCTAssertTrue(dashboardSource.contains("chatFirstRichBlockContext: chatFirstRichBlockContext")) - XCTAssertTrue(dashboardSource.contains("chatTranscriptFirstPageDidLoad()")) - let homeSource = try sourceFile("MainWindow/DesktopHomeView.swift") - XCTAssertTrue(homeSource.contains("if usesChatFirstShell,")) + // The context is a required binding on every host, not an optional capability. + XCTAssertTrue( + queryHomeSource.contains("let chatFirstRichBlockContext: ChatFirstRichBlockContext")) + XCTAssertTrue( + answerThreadSource.contains("let chatFirstRichBlockContext: ChatFirstRichBlockContext")) + XCTAssertTrue( + bubbleSource.contains("let chatFirstRichBlockContext: ChatFirstRichBlockContext")) + XCTAssertTrue(taskPanelSource.contains("chatFirstRichBlockContext: .auxiliary(")) } /// The fade is the notch's alone now that the standalone chat page is gone. @@ -215,7 +225,7 @@ final class DesktopChatDriftGuardTests: XCTestCase { /// an incoming reply — so this pins the notch and pins Home's abstention. func testTheTranscriptFadeIsTheNotchsAloneAndHomeAbstains() throws { let notchChat = try sourceFile("FloatingControlBar/AIResponseView.swift") - let home = try sourceFile("MainWindow/Pages/DashboardPage.swift") + let home = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") XCTAssertTrue(notchChat.contains(".overlay(alignment: .bottom) {\n ChatComposerFade()")) XCTAssertFalse( @@ -224,9 +234,9 @@ final class DesktopChatDriftGuardTests: XCTestCase { } func testChatTranscriptLoaderIgnoresSessionListRefreshes() throws { - let dashboardPage = try sourceFile("MainWindow/Pages/DashboardPage.swift") + let answerThread = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") - for source in [dashboardPage] { + for source in [answerThread] { XCTAssertFalse( source.contains("isLoadingInitial: (chatProvider.isLoading || chatProvider.isLoadingSessions)"), "Session-list refreshes must not hide a non-empty transcript behind the initial message-history loader." @@ -238,9 +248,9 @@ final class DesktopChatDriftGuardTests: XCTestCase { } XCTAssertEqual( - dashboardPage.components(separatedBy: "isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing") + answerThread.components(separatedBy: "isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing") .count - 1, - 2 + 1 ) } diff --git a/desktop/macos/Desktop/Tests/FloatingBarNotchCardSizingTests.swift b/desktop/macos/Desktop/Tests/FloatingBarNotchCardSizingTests.swift index 9852aecc7fa..3ce97a80d4a 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarNotchCardSizingTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarNotchCardSizingTests.swift @@ -60,7 +60,8 @@ final class FloatingBarNotchCardSizingTests: XCTestCase { ownerID: "test-owner", title: title, message: "User is asked to find learned lessons in Claude Code sessions", - assistantId: "proactive_assistant" + assistantId: "proactive_assistant", + kind: .memory ) } diff --git a/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift b/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift index 822e37ebb20..8a185854a9a 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift @@ -47,7 +47,8 @@ final class FloatingBarNotificationGroundTests: XCTestCase { ownerID: "test-owner", title: "Couldn't reach Omi", message: "Error 502", - assistantId: assistantID) + assistantId: assistantID, + kind: ProactiveNotificationKind.from(assistantId: assistantID)) return state } diff --git a/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift b/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift index d1efeff6c90..ed2c576f9f2 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift @@ -450,11 +450,12 @@ final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { @MainActor func testNotificationsAreNotPersistentByDefault() { let plain = FloatingBarNotification( - ownerID: "owner", title: "t", message: "m", assistantId: "default") + ownerID: "owner", title: "t", message: "m", assistantId: "default", kind: .functional) XCTAssertFalse(plain.isPersistent) let share = FloatingBarNotification( ownerID: "owner", title: "t", message: "m", assistantId: MeetingActionItemBannerPolicy.assistantID, + kind: .meetingNotes, action: .meetingSummaryShare(conversationID: "c1", recipients: []), isPersistent: true) XCTAssertTrue(share.isPersistent) diff --git a/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift b/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift index a0f82292852..07a75920d14 100644 --- a/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift @@ -61,7 +61,8 @@ final class FloatingOwnerProjectionTests: XCTestCase { title: "owner A private title", message: "owner A private content", assistantId: "insight", - sound: .none) + sound: .none, + kind: .insight) } await gate.waitUntilStarted() defaults.set("owner-b", forKey: .authUserId) @@ -99,6 +100,7 @@ final class FloatingOwnerProjectionTests: XCTestCase { message: "must not mark delivered", assistantId: "context-director", sound: .none, + kind: .insight, onPresented: { presentedCount += 1 }, onDropped: { droppedCount += 1 }) } diff --git a/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift b/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift index ee1f0640583..bc4a3b000fc 100644 --- a/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift +++ b/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift @@ -220,16 +220,17 @@ final class GlassLegibilityTests: XCTestCase { /// test could resolve ever changed. A component-scoped tripwire on the literal is the only thing /// that would have failed on that commit. /// - /// Scoped to Home's own file and to `Color(red:` specifically: a page hosted on the panel has no + /// Scoped to Home's own file — `QueryShellHome`, since `DashboardPage` was deleted — and to + /// `Color(red:` specifically: a page hosted on the panel has no /// business mixing its own opaque colour at all, and every legitimate surface on it is a token. func testStaticCheck_HomeMixesNoColourLiteralOfItsOwn() { let home = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // Tests .deletingLastPathComponent() // Desktop - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") + .appendingPathComponent("Sources/MainWindow/QueryShell/QueryShellHome.swift") // omi-test-quality: source-inspection -- static contract: which token a call site names is a source fact; a rendered view cannot report it. guard let source = try? String(contentsOf: home, encoding: .utf8) else { - return XCTFail("Could not read DashboardPage.swift at \(home.path)") + return XCTFail("Could not read QueryShellHome.swift at \(home.path)") } XCTAssertFalse( source.contains("Color(red:"), diff --git a/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift b/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift index 9a71ce6658c..bc763bfd569 100644 --- a/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift +++ b/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift @@ -94,10 +94,10 @@ final class GlassPanelHitRegionTests: XCTestCase { "the band above the panel is air and must not swallow a click aimed at another app") } - /// Legacy Home hosts both its primary navigation and Settings menu beside `PageGlassLane`. The - /// slot must give either menu the real shared glass and its matching hit region, while the modern - /// panel-hosted Settings menu must inherit the page panel instead of adding a second material. - func testLegacySidebarSlotOwnsGlassAndHitsForBothMenus() throws { + /// The panel-hosted Settings menu inherits the page panel rather than adding a second material, + /// so it owns no standalone glass surface and claims no hit region of its own. (The legacy + /// sidebar shell this used to compare against is gone.) + func testPanelHostedSettingsMenuAddsNoSecondSurface() throws { defer { teardownWindow() } for host in SidebarHost.allCases { @@ -166,13 +166,9 @@ final class GlassPanelHitRegionTests: XCTestCase { private enum SidebarHost: CaseIterable { case panelSettings - case legacySettings - case legacyNavigation - var expectsSurface: Bool { self != .panelSettings } - var width: CGFloat { - self == .legacyNavigation ? 64 : SettingsSidebarMetrics.expandedWidth - } + var expectsSurface: Bool { false } + var width: CGFloat { SettingsSidebarMetrics.expandedWidth } } private func mountSidebar(_ host: SidebarHost) -> NSRect { @@ -190,18 +186,6 @@ final class GlassPanelHitRegionTests: XCTestCase { switch host { case .panelSettings: sidebar = AnyView(settingsSidebar) - case .legacySettings: - sidebar = AnyView(LegacySidebarSurface(reduceTransparency: false) { settingsSidebar }) - case .legacyNavigation: - sidebar = AnyView( - LegacySidebarSurface(reduceTransparency: false) { - SidebarView( - selectedIndex: .constant(SidebarNavItem.dashboard.rawValue), - isCollapsed: .constant(true), - memoryDestinationRawValue: .constant(MemoryHubDestination.memories.rawValue), - appState: AppState() - ) - }) } let root = HStack(spacing: 0) { diff --git a/desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift b/desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift deleted file mode 100644 index 8f8b079b712..00000000000 --- a/desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import XCTest - -@testable import Omi_Computer - -/// Deterministic policy test for the stale deferred Home ask-field focus fix -/// (home-stage S6 regression). Exercises the real production policy directly — -/// no run loop, no sleeps. -final class HomeAskFocusPolicyTests: XCTestCase { - func testFreshTokenMatchesCurrentGeneration() { - let policy = HomeAskFocusPolicy() - let token = policy.currentToken() - XCTAssertTrue(policy.isCurrent(token)) - XCTAssertEqual(token.generation, 0) - } - - func testInvalidateStalesEveryPriorToken() { - let policy = HomeAskFocusPolicy() - let token = policy.currentToken() - - policy.invalidate() - - XCTAssertFalse( - policy.isCurrent(token), - "A token captured before an invalidate must no longer be current") - } - - func testTokenCapturedAfterInvalidateIsCurrent() { - let policy = HomeAskFocusPolicy() - let stale = policy.currentToken() - policy.invalidate() - let fresh = policy.currentToken() - - XCTAssertFalse(policy.isCurrent(stale)) - XCTAssertTrue(policy.isCurrent(fresh)) - } - - func testGenerationIsStrictlyMonotonic() { - let policy = HomeAskFocusPolicy() - let first = policy.currentToken() - - XCTAssertEqual(policy.invalidate(), 1) - XCTAssertEqual(policy.invalidate(), 2) - XCTAssertEqual(policy.invalidate(), 3) - - XCTAssertFalse(policy.isCurrent(first)) - XCTAssertTrue(policy.isCurrent(policy.currentToken())) - XCTAssertEqual(policy.generation, 3) - } - - /// The exact regression: `openHomeChat` schedules a deferred focus, then a - /// connect / collapse / close lands before the yielded focus resumes. The - /// deferred focus must be dropped, not applied (applying it would set the ask - /// field focused while not in chat, and the focus observer would reopen chat). - func testStaleDeferredFocusIsDroppedAfterCollapseOrClose() { - let policy = HomeAskFocusPolicy() - - // openHomeChat(focusInput: true) captures the generation it scheduled against. - let scheduledToken = policy.currentToken() - - // Before the yielded focus resumes, the user collapses (Esc / click-outside - // / connect ×) or the automation bridge closes — every one of these - // invalidates outstanding deferred focus. - policy.invalidate() - - // The deferred focus resumes and re-checks its generation: stale → skip. - XCTAssertFalse( - policy.isCurrent(scheduledToken), - "A deferred focus scheduled before a collapse/connect/close must be dropped") - } - - /// An unrelated later open must still be able to focus: invalidation only - /// kills the superseded generation, not subsequent ones. - func testSubsequentOpenCanStillFocusAfterAnInvalidate() { - let policy = HomeAskFocusPolicy() - _ = policy.currentToken() - policy.invalidate() - - let reopenedToken = policy.currentToken() - XCTAssertTrue(policy.isCurrent(reopenedToken)) - } -} diff --git a/desktop/macos/Desktop/Tests/HomeDailySummaryTests.swift b/desktop/macos/Desktop/Tests/HomeDailySummaryTests.swift index aba2326e183..b7e71a8d035 100644 --- a/desktop/macos/Desktop/Tests/HomeDailySummaryTests.swift +++ b/desktop/macos/Desktop/Tests/HomeDailySummaryTests.swift @@ -63,14 +63,20 @@ final class HomeDailySummaryTests: XCTestCase { XCTAssertEqual(HomeDailySummaryStatsRow.duration(130), "2h 10m") } - func testEyebrowFormatsDateAndFallsBackCleanly() throws { + /// The hub's `HomeDailySummarySection` eyebrow was the other renderer of this + /// date; it went with `DashboardPage`. The surviving surface is the Chat + /// card's pill, whose own rules are covered in `ChatDailySummaryTests`. + func testChatCardDatePillIsTheSurvivingDateRenderer() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "UTC")) - let eyebrow = HomeDailySummarySection.eyebrow( - for: "2026-09-01", calendar: calendar, locale: Locale(identifier: "en_US")) - XCTAssertEqual(eyebrow, "DAILY SUMMARY · TUE, SEP 1") - XCTAssertEqual(HomeDailySummarySection.eyebrow(for: nil), "DAILY SUMMARY") - XCTAssertEqual(HomeDailySummarySection.eyebrow(for: "not-a-date"), "DAILY SUMMARY") + let now = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2_026, month: 9, day: 2))) + XCTAssertEqual( + ChatDailySummaryPresentation.dateLabel( + for: "2026-09-01", now: now, calendar: calendar, locale: Locale(identifier: "en_US")), + "Yesterday") + XCTAssertNil( + ChatDailySummaryPresentation.dateLabel(for: "not-a-date", now: now, calendar: calendar)) } // MARK: store diff --git a/desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift b/desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift deleted file mode 100644 index 2cbdd318689..00000000000 --- a/desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift +++ /dev/null @@ -1,322 +0,0 @@ -import XCTest - -@testable import Omi_Computer - -final class HomeKnowsComposerTests: XCTestCase { - private let tasks = [ - HomeKnowsTaskCandidate(id: "t1", text: "Submit the Design PR by 7pm"), - HomeKnowsTaskCandidate(id: "t2", text: "Reply to Sarah"), - ] - private let insights = [ - HomeKnowsInsightCandidate(id: "i1", text: "Deepgram spend is pacing 18% over last week"), - HomeKnowsInsightCandidate(id: "i2", text: "Two meetings overlap on Thursday"), - ] - private let questions = ["What should I do today?", "What did I spend my time on this week?"] - - func testComposePicksTaskInsightTaskQuestionWhenAllAvailable() { - let rows = HomeKnowsListComposer.compose(tasks: tasks, insights: insights, questions: questions).rows - - // Diverse 4-slot brief: pressing task, one insight, a second task, then a prefilled ask. - XCTAssertEqual(rows.count, 4) - XCTAssertEqual(rows[0].kind, .task(id: "t1")) - XCTAssertEqual(rows[0].text, "Submit the Design PR by 7pm") - XCTAssertEqual(rows[1].kind, .insight(id: "i1")) - XCTAssertEqual(rows[2].kind, .task(id: "t2")) - XCTAssertEqual(rows[3].kind, .question) - XCTAssertEqual(rows[3].text, "What should I do today?") - } - - func testDismissedTaskFallsThroughToNextTask() { - let ledger = HomeKnowsLedgerFixture.dismissing(taskID: "t1", text: "Submit the Design PR by 7pm") - let rows = HomeKnowsListComposer.compose( - tasks: tasks, insights: insights, questions: questions, ledger: ledger - ).rows - - XCTAssertEqual(rows[0].kind, .task(id: "t2")) - } - - func testAllTasksDismissedFillsWithOneInsightAndQuestion() { - var ledger = HomeKnowsLedgerFixture.dismissing(taskID: "t1", text: "Submit the Design PR by 7pm") - ledger.entries.merge( - HomeKnowsLedgerFixture.dismissing(taskID: "t2", text: "Reply to Sarah").entries - ) { _, new in new } - - let composition = HomeKnowsListComposer.compose( - tasks: tasks, insights: insights, questions: questions, ledger: ledger) - - // At most one insight (the tip slot); the ask fills the remaining slot. - XCTAssertEqual(composition.rows.count, 2) - XCTAssertEqual(composition.rows[0].kind, .insight(id: "i1")) - XCTAssertEqual(composition.rows[1].kind, .question) - // Both task slots report why they stayed empty rather than repeating. - XCTAssertEqual( - composition.emptySlots, - [ - HomeKnowsEmptySlot(slot: .pressingTask, reason: .dismissed), - HomeKnowsEmptySlot(slot: .secondTask, reason: .dismissed), - ]) - } - - func testSingleAskWhenNoTasksOrInsights() { - let rows = HomeKnowsListComposer.compose( - tasks: [], insights: [], questions: questions + ["Third question?"] - ).rows - - // Only one prefilled ask is ever surfaced — the list never collapses into all-questions. - XCTAssertEqual(rows.count, 1) - XCTAssertEqual(rows[0].kind, .question) - XCTAssertEqual(rows[0].text, "What should I do today?") - } - - func testSecondTaskFillsLastSlotWhenNoQuestionExists() { - let rows = HomeKnowsListComposer.compose(tasks: tasks, insights: insights, questions: []).rows - - // With no ask, the last slot goes to a second task — never a second insight. - XCTAssertEqual(rows.count, 3) - XCTAssertEqual(rows[0].kind, .task(id: "t1")) - XCTAssertEqual(rows[1].kind, .insight(id: "i1")) - XCTAssertEqual(rows[2].kind, .task(id: "t2")) - } - - func testEmptyAndWhitespaceEntriesAreSkipped() { - let rows = HomeKnowsListComposer.compose( - tasks: [HomeKnowsTaskCandidate(id: "t0", text: " ")], - insights: [HomeKnowsInsightCandidate(id: "i0", text: "")], - questions: [" ", "Real question?"] - ).rows - - XCTAssertEqual(rows.count, 1) - XCTAssertEqual(rows[0].kind, .question) - XCTAssertEqual(rows[0].text, "Real question?") - } - - func testEverythingEmptyProducesNoRows() { - XCTAssertTrue(HomeKnowsListComposer.compose(tasks: [], insights: [], questions: []).rows.isEmpty) - } - - func testDuplicateQuestionsDoNotCollideAcrossQuestionRows() { - // Question rows derive their ForEach ID from the text, so a repeated - // suggestion must never surface twice. The redesign surfaces at most two - // question-kind rows (a composed tip in the second slot and a distinct ask - // in the last), so use a tip to exercise both and assert the repeat is - // dropped and the two IDs stay unique. - let rows = HomeKnowsListComposer.compose( - tasks: [], insights: [], - tip: "What should I do today?", - questions: ["What should I do today?", " What should I do today? ", "Second question?"] - ).rows - - XCTAssertEqual(rows.count, 2) - XCTAssertEqual(rows.map(\.kind), [.question, .question]) - XCTAssertEqual(rows.map(\.text), ["What should I do today?", "Second question?"]) - XCTAssertEqual(Set(rows.map(\.id)).count, rows.count) - } - - func testDuplicateTaskIDsAcrossBucketsSurfaceOnce() { - // The hub concatenates overdue + today + no-due-date, which can repeat a row. - let rows = HomeKnowsListComposer.compose( - tasks: tasks + [HomeKnowsTaskCandidate(id: "t1", text: "Submit the Design PR by 7pm")], - insights: [], questions: [] - ).rows - - XCTAssertEqual(rows.map(\.kind), [.task(id: "t1"), .task(id: "t2")]) - } - - // MARK: Rotation — the list gets shorter rather than repeating - - /// The reported defect: a thin source re-showed the same four rows on every - /// visit. With the ledger, the second visit that same day is short, not a repeat. - func testAlreadyShownRowsLeaveSlotsEmptyInsteadOfRepeating() { - let now = HomeKnowsLedgerFixture.noon - var ledger = HomeKnowsImpressionLedger.empty - for task in tasks { - ledger.entries[HomeKnowsRotationPolicy.taskKey(task.id)] = - HomeKnowsLedgerFixture.shownToday(text: task.text, now: now) - } - ledger.entries[HomeKnowsRotationPolicy.insightKey("i1")] = - HomeKnowsLedgerFixture.shownToday(text: insights[0].text, now: now) - - let composition = HomeKnowsListComposer.compose( - tasks: tasks, insights: insights, questions: questions, ledger: ledger, now: now) - - // Both tasks and the first insight were shown today; only the untouched - // insight and the ask still qualify. The list is shorter, never repeated. - XCTAssertEqual(composition.rows.map(\.kind), [.insight(id: "i2"), .question]) - XCTAssertEqual( - composition.emptySlots, - [ - HomeKnowsEmptySlot(slot: .pressingTask, reason: .sameDay), - HomeKnowsEmptySlot(slot: .secondTask, reason: .sameDay), - ]) - } - - func testEverySourceExhaustedProducesAnEmptyListNotAFallbackRepeat() { - let now = HomeKnowsLedgerFixture.noon - let tip = "Recap what I got done today" - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries[HomeKnowsRotationPolicy.taskKey("t1")] = HomeKnowsLedgerFixture.shownToday( - text: tasks[0].text, now: now) - ledger.entries[HomeKnowsRotationPolicy.questionKey(questions[0])] = - HomeKnowsLedgerFixture.shownToday(text: questions[0], now: now) - ledger.entries[HomeKnowsRotationPolicy.questionKey(tip)] = - HomeKnowsLedgerFixture.shownToday(text: tip, now: now) - - let composition = HomeKnowsListComposer.compose( - tasks: [tasks[0]], insights: [], tip: tip, questions: [questions[0]], ledger: ledger, now: now) - - XCTAssertTrue(composition.rows.isEmpty) - XCTAssertEqual(composition.emptySlots.map(\.slot), [.pressingTask, .tip, .secondTask, .ask]) - XCTAssertTrue(composition.emptySlots.allSatisfy { $0.reason == .sameDay }) - } - - func testSameDayRepeatIsAllowedOnlyForAPreviouslyOpenedRow() { - let now = HomeKnowsLedgerFixture.noon - var ledger = HomeKnowsImpressionLedger.empty - var opened = HomeKnowsLedgerFixture.shownToday(text: tasks[0].text, now: now) - opened.lastOpenedAt = HomeKnowsLedgerFixture.sameDay(as: now) - ledger.entries[HomeKnowsRotationPolicy.taskKey("t1")] = opened - - // Nothing else qualifies for the task slot, and this row has been opened - // before, so the same-day rule relaxes for it. - let composition = HomeKnowsListComposer.compose( - tasks: [tasks[0]], insights: [], questions: [], ledger: ledger, now: now) - - XCTAssertEqual(composition.rows.map(\.kind), [.task(id: "t1")]) - } - - func testAnotherQualifyingTaskWinsOverASameDayRepeat() { - let now = HomeKnowsLedgerFixture.noon - var ledger = HomeKnowsImpressionLedger.empty - var opened = HomeKnowsLedgerFixture.shownToday(text: tasks[0].text, now: now) - opened.lastOpenedAt = HomeKnowsLedgerFixture.sameDay(as: now) - ledger.entries[HomeKnowsRotationPolicy.taskKey("t1")] = opened - - let composition = HomeKnowsListComposer.compose( - tasks: tasks, insights: [], questions: [], ledger: ledger, now: now) - - // t2 has never been shown, so the strict pass succeeds and t1 stays out. - XCTAssertEqual(composition.rows.map(\.kind), [.task(id: "t2")]) - } - - func testStaleAndCompletedTasksAreExcluded() { - let now = HomeKnowsLedgerFixture.noon - let candidates = [ - HomeKnowsTaskCandidate(id: "old", text: "Long-dead commitment", dueAt: now.addingTimeInterval(-20 * 86_400)), - HomeKnowsTaskCandidate(id: "done", text: "Finished thing", isActive: false), - HomeKnowsTaskCandidate(id: "live", text: "Still open", dueAt: now.addingTimeInterval(-3 * 86_400)), - ] - - let composition = HomeKnowsListComposer.compose( - tasks: candidates, insights: [], questions: [], now: now) - - XCTAssertEqual(composition.rows.map(\.kind), [.task(id: "live")]) - } - - func testFreshnessOrderPrefersNeverShownThenFewestShows() { - let now = HomeKnowsLedgerFixture.noon - let yesterday = HomeKnowsLedgerFixture.previousDay(before: now) - let candidates = [ - HomeKnowsTaskCandidate(id: "seenTwice", text: "Seen twice"), - HomeKnowsTaskCandidate(id: "seenOnce", text: "Seen once"), - HomeKnowsTaskCandidate(id: "neverSeen", text: "Never seen"), - ] - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries[HomeKnowsRotationPolicy.taskKey("seenTwice")] = HomeKnowsImpression( - shows: 2, lastShownAt: yesterday, - contentHash: HomeKnowsRotationPolicy.contentHash(text: "Seen twice")) - ledger.entries[HomeKnowsRotationPolicy.taskKey("seenOnce")] = HomeKnowsImpression( - shows: 1, lastShownAt: yesterday, - contentHash: HomeKnowsRotationPolicy.contentHash(text: "Seen once")) - - let composition = HomeKnowsListComposer.compose( - tasks: candidates, insights: [], questions: [], ledger: ledger, now: now) - - XCTAssertEqual(composition.rows.map(\.kind), [.task(id: "neverSeen"), .task(id: "seenOnce")]) - XCTAssertEqual(composition.rows.map(\.showsBefore), [0, 1]) - } - - /// Equal freshness must fall back to the caller's own priority order. An - /// earlier revision tie-broke on the ledger key, which is a hash, and silently - /// reordered the suggested questions the caller had already ranked. - func testEquallyFreshCandidatesKeepTheCallersOrder() { - let composition = HomeKnowsListComposer.compose( - tasks: [], insights: [], questions: questions, now: HomeKnowsLedgerFixture.noon) - - XCTAssertEqual(composition.rows.first?.text, questions[0]) - } - - func testFreshnessOrderBreaksTiesOnMostRecentUpdate() { - let now = HomeKnowsLedgerFixture.noon - let candidates = [ - HomeKnowsTaskCandidate(id: "stale", text: "Older update", updatedAt: now.addingTimeInterval(-7200)), - HomeKnowsTaskCandidate(id: "fresh", text: "Newer update", updatedAt: now.addingTimeInterval(-60)), - ] - - let composition = HomeKnowsListComposer.compose( - tasks: candidates, insights: [], questions: [], now: now) - - XCTAssertEqual(composition.rows.first?.kind, .task(id: "fresh")) - } - - func testCanRotateOnlyWhenMoreCandidatesStillQualify() { - let now = HomeKnowsLedgerFixture.noon - XCTAssertFalse( - HomeKnowsListComposer.compose(tasks: tasks, insights: [], questions: [], now: now).canRotate) - - let three = tasks + [HomeKnowsTaskCandidate(id: "t3", text: "Third task")] - XCTAssertTrue( - HomeKnowsListComposer.compose(tasks: three, insights: [], questions: [], now: now).canRotate) - - // A third task that no longer qualifies does not make the list rotatable. - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries[HomeKnowsRotationPolicy.taskKey("t3")] = HomeKnowsLedgerFixture.shownToday( - text: "Third task", now: now) - XCTAssertFalse( - HomeKnowsListComposer.compose(tasks: three, insights: [], questions: [], ledger: ledger, now: now) - .canRotate) - } - - func testOpenTaskCountIgnoresDismissedAndCompletedTasks() { - let ledger = HomeKnowsLedgerFixture.dismissing(taskID: "t1", text: "Submit the Design PR by 7pm") - let candidates = tasks + [HomeKnowsTaskCandidate(id: "done", text: "Finished", isActive: false)] - - XCTAssertEqual(HomeKnowsListComposer.openTaskCount(candidates), 2) - XCTAssertEqual(HomeKnowsListComposer.openTaskCount(candidates, ledger: ledger), 1) - } -} - -/// Shared ledger fixtures. A fixed instant keeps the calendar-day rules -/// deterministic regardless of when the suite runs. -enum HomeKnowsLedgerFixture { - /// 2026-03-07 12:00:00 UTC — mid-day, so "same calendar day" is unambiguous. - static let noon = Date(timeIntervalSince1970: 1_772_884_800) - - /// An instant guaranteed to be the same *local* calendar day as `now`, so the - /// same-day rule is asserted the same way in every timezone the suite runs in. - static func sameDay(as now: Date, calendar: Calendar = .current) -> Date { - calendar.startOfDay(for: now) - } - - static func previousDay(before now: Date, calendar: Calendar = .current) -> Date { - calendar.startOfDay(for: now).addingTimeInterval(-1) - } - - static func shownToday(text: String, now: Date, shows: Int = 1) -> HomeKnowsImpression { - HomeKnowsImpression( - shows: shows, - firstShownAt: sameDay(as: now), - lastShownAt: sameDay(as: now), - contentHash: HomeKnowsRotationPolicy.contentHash(text: text)) - } - - static func dismissing(taskID: String, text: String, at date: Date = noon) -> HomeKnowsImpressionLedger { - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries[HomeKnowsRotationPolicy.taskKey(taskID)] = HomeKnowsImpression( - shows: 1, - firstShownAt: date, - lastShownAt: date, - dismissedAt: date, - contentHash: HomeKnowsRotationPolicy.contentHash(text: text)) - return ledger - } -} diff --git a/desktop/macos/Desktop/Tests/HomeKnowsImpressionLedgerTests.swift b/desktop/macos/Desktop/Tests/HomeKnowsImpressionLedgerTests.swift deleted file mode 100644 index 8fd01a88eb6..00000000000 --- a/desktop/macos/Desktop/Tests/HomeKnowsImpressionLedgerTests.swift +++ /dev/null @@ -1,378 +0,0 @@ -import XCTest - -@testable import Omi_Computer - -// MARK: - Rules - -final class HomeKnowsRotationPolicyTests: XCTestCase { - private let now = HomeKnowsLedgerFixture.noon - private let calendar = Calendar.current - - private func facts( - _ key: String = "task:t1", - text: String = "Meet with Priya", - updatedAt: Date? = nil, - dueAt: Date? = nil, - isActive: Bool = true - ) -> HomeKnowsCandidateFacts { - HomeKnowsCandidateFacts( - key: key, - contentHash: HomeKnowsRotationPolicy.contentHash(text: text, updatedAt: updatedAt), - updatedAt: updatedAt, - dueAt: dueAt, - isActive: isActive) - } - - private func suppression( - _ facts: HomeKnowsCandidateFacts, - _ entry: HomeKnowsImpression?, - at instant: Date? = nil, - allowSameDayRepeat: Bool = false - ) -> HomeKnowsRotationReason? { - HomeKnowsRotationPolicy.suppression( - facts: facts, - entry: entry, - now: instant ?? now, - calendar: calendar, - allowSameDayRepeat: allowSameDayRepeat) - } - - func testNeverShownRowQualifies() { - XCTAssertNil(suppression(facts(), nil)) - } - - func testThreeShowsWithoutAnOpenRotateOutForSevenDays() { - let subject = facts() - let lastShown = HomeKnowsLedgerFixture.previousDay(before: now) - let entry = HomeKnowsImpression( - shows: 3, firstShownAt: lastShown, lastShownAt: lastShown, contentHash: subject.contentHash) - - XCTAssertEqual(suppression(subject, entry), .showCap) - // Still out one day before the cooldown ends… - XCTAssertEqual( - suppression(subject, entry, at: lastShown.addingTimeInterval(6 * 86_400)), .showCap) - // …and back once it has passed. - XCTAssertNil(suppression(subject, entry, at: lastShown.addingTimeInterval(7 * 86_400 + 1))) - } - - func testTwoShowsDoNotHitTheCap() { - let subject = facts() - let lastShown = HomeKnowsLedgerFixture.previousDay(before: now) - let entry = HomeKnowsImpression(shows: 2, lastShownAt: lastShown, contentHash: subject.contentHash) - - XCTAssertNil(suppression(subject, entry)) - } - - func testAnOpenedRowIsNeverCapped() { - let subject = facts() - let lastShown = HomeKnowsLedgerFixture.previousDay(before: now) - let entry = HomeKnowsImpression( - shows: 12, lastShownAt: lastShown, lastOpenedAt: lastShown, contentHash: subject.contentHash) - - XCTAssertNil(suppression(subject, entry)) - } - - func testDismissedRowNeverReturnsWhileItsObjectIsUnchanged() { - let subject = facts() - let entry = HomeKnowsImpression( - shows: 1, dismissedAt: HomeKnowsLedgerFixture.previousDay(before: now), - contentHash: subject.contentHash) - - XCTAssertEqual(suppression(subject, entry), .dismissed) - // Even a year later, and even when nothing else qualifies for the slot. - XCTAssertEqual(suppression(subject, entry, at: now.addingTimeInterval(365 * 86_400)), .dismissed) - XCTAssertEqual(suppression(subject, entry, allowSameDayRepeat: true), .dismissed) - } - - func testDismissedRowReturnsOnceItsUnderlyingObjectChanges() { - let dismissed = facts(text: "Meet with Priya") - let entry = HomeKnowsImpression( - shows: 1, dismissedAt: HomeKnowsLedgerFixture.previousDay(before: now), - contentHash: dismissed.contentHash) - - // Same row id, new content hash — a real edit to the task. - let edited = facts(text: "Meet with Priya about the Q3 plan") - XCTAssertNil(suppression(edited, entry)) - - // A new updated_at alone is enough, even with identical text. - let touched = facts(text: "Meet with Priya", updatedAt: now) - XCTAssertNil(suppression(touched, entry)) - } - - func testTaskMoreThanFourteenDaysPastDueIsExcluded() { - XCTAssertNil(suppression(facts(dueAt: now.addingTimeInterval(-13 * 86_400)), nil)) - XCTAssertEqual( - suppression(facts(dueAt: now.addingTimeInterval(-15 * 86_400)), nil), .staleDueDate) - // A future due date is never stale. - XCTAssertNil(suppression(facts(dueAt: now.addingTimeInterval(86_400)), nil)) - } - - func testCompletedOrDeletedTaskIsExcluded() { - XCTAssertEqual(suppression(facts(isActive: false), nil), .inactive) - } - - func testSameDayRepeatNeedsBothRelaxationAndAPriorOpen() { - let subject = facts() - let today = HomeKnowsLedgerFixture.sameDay(as: now) - let unopened = HomeKnowsImpression(shows: 1, lastShownAt: today, contentHash: subject.contentHash) - var opened = unopened - opened.lastOpenedAt = today - - XCTAssertEqual(suppression(subject, unopened), .sameDay) - XCTAssertEqual(suppression(subject, unopened, allowSameDayRepeat: true), .sameDay) - XCTAssertEqual(suppression(subject, opened), .sameDay) - XCTAssertNil(suppression(subject, opened, allowSameDayRepeat: true)) - } - - func testFreshnessRankPrefersNeverShownThenFewestShowsThenNewestUpdate() { - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries["task:seen"] = HomeKnowsImpression(shows: 2) - let never = facts("task:never") - let seen = facts("task:seen") - - XCTAssertTrue( - HomeKnowsRotationPolicy.freshnessRank(never, ledger: ledger) - < HomeKnowsRotationPolicy.freshnessRank(seen, ledger: ledger)) - - let older = facts("task:a", updatedAt: now.addingTimeInterval(-3600)) - let newer = facts("task:b", updatedAt: now) - XCTAssertTrue( - HomeKnowsRotationPolicy.freshnessRank(newer, ledger: .empty) - < HomeKnowsRotationPolicy.freshnessRank(older, ledger: .empty)) - - // Two equally fresh rows must tie, so the caller's own priority order — not - // the row key — decides between them. - XCTAssertTrue( - HomeKnowsRotationPolicy.freshnessRank(facts("task:zzz"), ledger: .empty) - == HomeKnowsRotationPolicy.freshnessRank(facts("task:aaa"), ledger: .empty)) - } - - func testDominantReasonUsesAFixedPriorityRatherThanInputOrder() { - XCTAssertEqual(HomeKnowsRotationPolicy.dominantReason([.sameDay, .dismissed]), .dismissed) - XCTAssertEqual(HomeKnowsRotationPolicy.dominantReason([.staleDueDate, .showCap]), .showCap) - XCTAssertEqual(HomeKnowsRotationPolicy.dominantReason([]), .noCandidate) - } - - func testContentHashMovesWithTextAndUpdatedAt() { - let base = HomeKnowsRotationPolicy.contentHash(text: "Meet with Priya") - XCTAssertEqual(base, HomeKnowsRotationPolicy.contentHash(text: "Meet with Priya")) - XCTAssertNotEqual(base, HomeKnowsRotationPolicy.contentHash(text: "Meet with Ravi")) - XCTAssertNotEqual(base, HomeKnowsRotationPolicy.contentHash(text: "Meet with Priya", updatedAt: now)) - } - - func testQuestionKeyHashesTheTextRatherThanStoringIt() { - let key = HomeKnowsRotationPolicy.questionKey("What did I commit to this week?") - XCTAssertTrue(key.hasPrefix("question:")) - XCTAssertFalse(key.contains("commit")) - XCTAssertEqual(key, HomeKnowsRotationPolicy.questionKey("What did I commit to this week?")) - } -} - -// MARK: - Store - -/// Not `@MainActor` at the class level: `XCTestCase.setUp` is a nonisolated -/// override and cannot build main-actor state. Each test makes its own harness. -final class HomeKnowsImpressionStoreTests: XCTestCase { - /// In-memory persistence plus a movable clock, so the rules are asserted - /// without UserDefaults and without waiting on the wall clock. - @MainActor - private final class Harness { - final class FakePersistence: HomeKnowsImpressionPersisting { - var ledger = HomeKnowsImpressionLedger.empty - - func load() -> HomeKnowsImpressionLedger { ledger } - func save(_ ledger: HomeKnowsImpressionLedger) { self.ledger = ledger } - } - - let persistence = FakePersistence() - var clock = HomeKnowsLedgerFixture.noon - lazy var store = HomeKnowsImpressionStore(persistence: persistence, now: { self.clock }) - let hash = HomeKnowsRotationPolicy.contentHash(text: "Meet with Priya") - } - - @MainActor - func testShowIsRecordedOncePerVisitNotOncePerRender() { - let harness = Harness() - harness.store.beginVisit() - XCTAssertEqual(harness.store.recordShown(key: "task:t1", contentHash: harness.hash)?.shows, 1) - // The in-visit rotation timer re-renders the same row every few seconds. - XCTAssertNil(harness.store.recordShown(key: "task:t1", contentHash: harness.hash)) - XCTAssertNil(harness.store.recordShown(key: "task:t1", contentHash: harness.hash)) - XCTAssertEqual(harness.persistence.ledger.entry("task:t1")?.shows, 1) - - harness.store.beginVisit() - XCTAssertEqual(harness.store.recordShown(key: "task:t1", contentHash: harness.hash)?.shows, 2) - } - - @MainActor - func testEmptySlotIsReportedOncePerVisit() { - let harness = Harness() - harness.store.beginVisit() - XCTAssertTrue(harness.store.shouldReportEmptySlot("tip")) - XCTAssertFalse(harness.store.shouldReportEmptySlot("tip")) - harness.store.beginVisit() - XCTAssertTrue(harness.store.shouldReportEmptySlot("tip")) - } - - @MainActor - func testFirstAndLastShownTrackSeparately() { - let harness = Harness() - harness.store.beginVisit() - harness.store.recordShown(key: "task:t1", contentHash: harness.hash) - let first = harness.clock - harness.clock = harness.clock.addingTimeInterval(2 * 86_400) - harness.store.beginVisit() - harness.store.recordShown(key: "task:t1", contentHash: harness.hash) - - let entry = harness.store.snapshot().entry("task:t1") - XCTAssertEqual(entry?.firstShownAt, first) - XCTAssertEqual(entry?.lastShownAt, harness.clock) - XCTAssertEqual(entry?.shows, 2) - } - - @MainActor - func testShowCountResetsAfterTheCooldownSoTheCapIsThreeShowsPerWindow() { - let harness = Harness() - for _ in 0..<HomeKnowsRotationPolicy.showCapCount { - harness.store.beginVisit() - harness.store.recordShown(key: "task:t1", contentHash: harness.hash) - harness.clock = harness.clock.addingTimeInterval(86_400) - } - XCTAssertEqual(harness.store.snapshot().entry("task:t1")?.shows, 3) - - harness.clock = harness.clock.addingTimeInterval(HomeKnowsRotationPolicy.showCapCooldown) - harness.store.beginVisit() - XCTAssertEqual(harness.store.recordShown(key: "task:t1", contentHash: harness.hash)?.shows, 1) - } - - @MainActor - func testChangedContentResetsTheCountAndClearsADismissal() { - let harness = Harness() - harness.store.beginVisit() - harness.store.recordShown(key: "task:t1", contentHash: harness.hash) - harness.store.recordDismissed(key: "task:t1", contentHash: harness.hash) - XCTAssertNotNil(harness.store.snapshot().entry("task:t1")?.dismissedAt) - - let edited = HomeKnowsRotationPolicy.contentHash(text: "Meet with Priya about Q3") - harness.store.beginVisit() - let entry = harness.store.recordShown(key: "task:t1", contentHash: edited) - XCTAssertEqual(entry?.shows, 1) - XCTAssertNil(entry?.dismissedAt) - XCTAssertEqual(entry?.contentHash, edited) - } - - @MainActor - func testOpeningARowClearsAnEarlierDismissal() { - let harness = Harness() - harness.store.beginVisit() - harness.store.recordDismissed(key: "insight:i1", contentHash: harness.hash) - let entry = harness.store.recordOpened(key: "insight:i1", contentHash: harness.hash) - - XCTAssertNil(entry.dismissedAt) - XCTAssertEqual(entry.lastOpenedAt, harness.clock) - } - - /// The composer reads the store's snapshot, so the two must agree: a row the - /// store has recorded three times is one the policy holds back. - @MainActor - func testStoreAndPolicyAgreeOnTheShowCap() { - let harness = Harness() - for _ in 0..<HomeKnowsRotationPolicy.showCapCount { - harness.store.beginVisit() - harness.store.recordShown(key: "task:t1", contentHash: harness.hash) - harness.clock = harness.clock.addingTimeInterval(86_400) - } - - let composition = HomeKnowsListComposer.compose( - tasks: [HomeKnowsTaskCandidate(id: "t1", text: "Meet with Priya")], - insights: [], questions: [], - ledger: harness.store.snapshot(), now: harness.clock) - - XCTAssertTrue(composition.rows.isEmpty) - XCTAssertEqual( - composition.emptySlots.first, HomeKnowsEmptySlot(slot: .pressingTask, reason: .showCap)) - } -} - -// MARK: - Persistence - -/// Not `@MainActor` at the class level: `XCTestCase.setUp`/`tearDown` are -/// nonisolated overrides and cannot touch main-actor state. The individual -/// tests carry the isolation the store requires. -final class HomeKnowsImpressionDefaultsTests: XCTestCase { - private var suiteName = "" - private var defaults = UserDefaults.standard - - override func setUp() { - super.setUp() - suiteName = "HomeKnowsImpressionDefaultsTests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) ?? .standard - } - - override func tearDown() { - UserDefaults.standard.removePersistentDomain(forName: suiteName) - super.tearDown() - } - - @MainActor - func testLedgerRoundTrips() { - let now = HomeKnowsLedgerFixture.noon - let store = HomeKnowsImpressionDefaults(defaults: defaults, ownerID: "owner-a", now: { now }) - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries["task:t1"] = HomeKnowsImpression(shows: 2, lastShownAt: now, contentHash: "abc") - store.save(ledger) - - let reader = HomeKnowsImpressionDefaults(defaults: defaults, ownerID: "owner-a", now: { now }) - XCTAssertEqual(reader.load(), ledger) - } - - @MainActor - func testOneOwnersDismissalsDoNotSilenceAnother() { - let owner = HomeKnowsImpressionDefaults(defaults: defaults, ownerID: "owner-a") - // Owner-b must see nothing regardless of retention, so both readers share a clock. - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries["task:t1"] = HomeKnowsImpression( - dismissedAt: HomeKnowsLedgerFixture.noon, contentHash: "abc") - owner.save(ledger) - - let other = HomeKnowsImpressionDefaults( - defaults: defaults, ownerID: "owner-b", now: { HomeKnowsLedgerFixture.noon }) - XCTAssertEqual(other.load(), .empty) - } - - @MainActor - func testUntouchedEntriesArePrunedOnLoad() { - let now = HomeKnowsLedgerFixture.noon - let writer = HomeKnowsImpressionDefaults(defaults: defaults, ownerID: "owner-a") - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries["task:recent"] = HomeKnowsImpression( - shows: 1, lastShownAt: now.addingTimeInterval(-30 * 86_400), contentHash: "a") - ledger.entries["task:ancient"] = HomeKnowsImpression( - shows: 1, lastShownAt: now.addingTimeInterval(-200 * 86_400), contentHash: "b") - writer.save(ledger) - - let loaded = HomeKnowsImpressionDefaults( - defaults: defaults, ownerID: "owner-a", now: { now } - ).load() - - XCTAssertEqual(Set(loaded.entries.keys), ["task:recent"]) - } - - @MainActor - func testCorruptPayloadLoadsAsAnEmptyLedgerRatherThanTrapping() { - let store = HomeKnowsImpressionDefaults( - defaults: defaults, ownerID: "owner-a", now: { HomeKnowsLedgerFixture.noon }) - var ledger = HomeKnowsImpressionLedger.empty - ledger.entries["task:t1"] = HomeKnowsImpression( - shows: 1, lastShownAt: HomeKnowsLedgerFixture.noon, contentHash: "abc") - store.save(ledger) - - // Overwrite whatever key the store chose with something undecodable; a bad - // payload must degrade to "no history", never trap the process. - let storageKey = defaults.dictionaryRepresentation().keys.first { $0.hasPrefix("homeKnows.") } - XCTAssertNotNil(storageKey) - defaults.set(Data("not json".utf8), forKey: storageKey ?? "") - - XCTAssertEqual(store.load(), .empty) - } -} diff --git a/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift b/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift index 6a68e80b3aa..723082da147 100644 --- a/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift +++ b/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift @@ -630,14 +630,20 @@ final class ChatMessageTimestampFormatTests: XCTestCase { final class ChatBubbleLayoutRegressionTests: XCTestCase { func testCollapsedReplyKeepsAnEllipsisBeforeTheBelowMessageExpansionControl() { - let source = String(repeating: "reply ", count: 100) - let collapsed = ChatBubbleTruncation.displayText(source, isStreaming: false, isExpanded: false) + let budget = ChatBubbleTruncation.Budget(lines: 12, charactersPerLine: 40) + let source = (1...40).map { "Line \($0) of a long reply that keeps going" }.joined(separator: "\n") + let collapsed = ChatBubbleTruncation.displayText( + source, isStreaming: false, isExpanded: false, budget: budget) - XCTAssertTrue(ChatBubbleTruncation.shouldTruncate(text: source, isStreaming: false, isExpanded: false)) + XCTAssertTrue( + ChatBubbleTruncation.shouldTruncate(text: source, isStreaming: false, isExpanded: false, budget: budget)) XCTAssertTrue(collapsed.hasSuffix("…"), "collapsed body must expose an ellipsis before Show more") - XCTAssertEqual(collapsed.count, ChatBubbleTruncation.threshold + 1) + XCTAssertTrue(collapsed.hasPrefix("Line 1 of"), "the start of the message stays visible") + XCTAssertLessThanOrEqual( + ChatBubbleTruncation.estimatedLines(String(collapsed.dropLast()), charactersPerLine: 40), + Double(budget.lines)) XCTAssertEqual( - ChatBubbleTruncation.displayText(source, isStreaming: false, isExpanded: true), + ChatBubbleTruncation.displayText(source, isStreaming: false, isExpanded: true, budget: budget), source, "expanding must restore the complete reply" ) @@ -653,6 +659,102 @@ final class ChatBubbleLayoutRegressionTests: XCTestCase { ) } + /// Five hundred characters was five lines, and every real answer collapsed. + /// A reply may fill two screens before the transcript offers to fold it. + func testTwoScreensOfProseFitBeforeTheCollapseOffer() { + let budget = ChatBubbleTruncation.budget(viewportHeight: 720, columnWidth: 640) + // 720 pt of 14 pt type with the transcript's leading is about 32 lines a screen. + XCTAssertGreaterThanOrEqual(budget.lines, 60) + XCTAssertLessThanOrEqual(budget.lines, 72) + XCTAssertGreaterThanOrEqual(budget.charactersPerLine, 70) + + let aboutOneScreen = String(repeating: "Prose that wraps naturally across the column. ", count: 50) + XCTAssertFalse( + ChatBubbleTruncation.shouldTruncate( + text: aboutOneScreen, isStreaming: false, isExpanded: false, budget: budget), + "a screen of prose is not collapsed") + + let fiveScreens = String(repeating: aboutOneScreen + "\n\n", count: 5) + XCTAssertTrue( + ChatBubbleTruncation.shouldTruncate( + text: fiveScreens, isStreaming: false, isExpanded: false, budget: budget), + "five screens start collapsed") + } + + func testABulletedReplyIsMeasuredInLinesNotCharacters() { + let budget = ChatBubbleTruncation.Budget(lines: 20, charactersPerLine: 80) + // 30 short bullets are 30 lines, though only 600 characters. + let bullets = (1...30).map { "- item \($0)" }.joined(separator: "\n") + XCTAssertTrue(ChatBubbleTruncation.exceedsBudget(bullets, budget: budget)) + // The same character count in one paragraph is eight lines. + let paragraph = String(repeating: "x", count: 600) + XCTAssertFalse(ChatBubbleTruncation.exceedsBudget(paragraph, budget: budget)) + } + + func testACollapsedBodyCutsAtALineNotMidWord() { + let budget = ChatBubbleTruncation.Budget(lines: 3, charactersPerLine: 100) + let source = ["First line.", "Second line.", "Third line.", "Fourth line.", "Fifth line."] + .joined(separator: "\n") + + XCTAssertEqual( + ChatBubbleTruncation.collapsedPrefix(source, budget: budget), + "First line.\nSecond line.\nThird line.") + } + + func testACutInsideAFenceClosesTheFence() { + let budget = ChatBubbleTruncation.Budget(lines: 4, charactersPerLine: 80) + let source = "Intro\n```swift\nlet a = 1\nlet b = 2\nlet c = 3\nlet d = 4\n```\nAfter" + let collapsed = ChatBubbleTruncation.displayText( + source, isStreaming: false, isExpanded: false, budget: budget) + + XCTAssertEqual(collapsed.components(separatedBy: "```").count - 1, 2, "the kept fence is closed") + XCTAssertTrue(collapsed.hasSuffix("…")) + XCTAssertFalse(collapsed.contains("let c")) + } + + func testTheBudgetFollowsTheTranscriptsGeometry() { + let standard = ChatBubbleTruncation.budget(viewportHeight: 720, columnWidth: 640) + XCTAssertGreaterThan( + ChatBubbleTruncation.budget(viewportHeight: 1400, columnWidth: 640).lines, standard.lines, + "a taller transcript shows more before folding") + XCTAssertLessThan( + ChatBubbleTruncation.budget(viewportHeight: 720, columnWidth: 320).charactersPerLine, + standard.charactersPerLine, + "a narrower column wraps sooner") + XCTAssertLessThan( + ChatBubbleTruncation.budget(viewportHeight: 720, columnWidth: 640, fontScale: 1.5).lines, + standard.lines, + "larger type fits fewer lines in the same screens") + XCTAssertEqual( + ChatBubbleTruncation.budget(viewportHeight: 0, columnWidth: 0), ChatBubbleTruncation.Budget.fallback, + "an unmeasured transcript uses the fallback rather than collapsing everything") + } + + /// An answer collapsing at the moment it settles is the one case truncation + /// must not cover: the reader watched the whole thing arrive, and the + /// transcript was following it down. + func testAnAnswerTheReaderJustWatchedArriveIsNotCollapsedWhenItSettles() { + XCTAssertTrue( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: true, isStreaming: false), + "a stream that just ended keeps the body the reader was reading") + XCTAssertTrue( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: true, isStreaming: nil), + "a row that loses its streaming flag entirely settled just the same") + } + + /// Restored history is what truncation is for, so nothing about merely + /// appearing may expand a row. + func testRestoredHistoryStillCollapses() { + XCTAssertFalse( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: false, isStreaming: false), + "a row that was never streaming here is history, and history stays compact") + XCTAssertFalse( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: nil, isStreaming: false)) + XCTAssertFalse( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: true, isStreaming: true), + "still streaming is not settled") + } + } final class ChatTranscriptWindowTests: XCTestCase { diff --git a/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift b/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift index 0c186c0a0c4..309bd70daec 100644 --- a/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift +++ b/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift @@ -97,97 +97,4 @@ final class HomeStageCloseSemanticsTests: XCTestCase { // MARK: Flow (static contract over DashboardPage wiring) - /// hub → chat → connect → close must collapse to the resting surface, and a - /// later `home_ask` must rest in chat — never force-jump to the hub. - func testAutomationCloseRoutesToUserCollapseNotHubJump() throws { - let source = try dashboardSource() - - XCTAssertFalse( - source.contains("closeHomeStagePanel"), - "The divergent hub-jump close path must stay gone; close routes through collapseHomeStagePanel") - - let closeHandler = try XCTUnwrap(source.range(of: ".homeStageClose")) - let handlerSlice = source[closeHandler.lowerBound...].prefix(300) - XCTAssertTrue( - handlerSlice.contains("collapseHomeStagePanel()"), - "home_close_panel must call the same collapse the on-screen controls call") - } - - /// The deferred focus must fence itself: capture a generation token, drop on - /// invalidate, and never land off the chat stage. Collapse and connect must - /// both invalidate it. - func testDeferredFocusFenceIsWired() throws { - let source = try dashboardSource() - - XCTAssertTrue( - source.contains("guard homeAskFocusPolicy.isCurrent(token), homeMode == .chat else { return }"), - "A deferred focus must drop itself if invalidated and never land on a non-chat stage") - - let invalidateCount = source.components(separatedBy: "homeAskFocusPolicy.invalidate()").count - 1 - XCTAssertEqual( - invalidateCount, 2, - "Both collapseHomeStagePanel and toggleHomeConnectPanel must invalidate deferred focus") - } - - /// Asking (via the ask bar) opens chat. After history restoration, the - /// resting surface follows the shared history-presentation policy. - func testHomeRestingModeFollowsLoadedHistoryPolicy() throws { - let source = try dashboardSource() - - let resting = try computedPropertyBody(named: "homeRestingMode", in: source) - XCTAssertTrue(resting.contains("HomeHistoryPresentationPolicy.restingMode(")) - XCTAssertTrue(resting.contains("isLoading: chatProvider.isLoading")) - XCTAssertTrue(resting.contains("messageCount: chatProvider.messages.count")) - - let ask = try methodBody(named: "sendFromHomeAskBar", in: source) - XCTAssertTrue( - ask.contains("openHomeChat(focusInput: false)"), - "Sending from the ask bar must open the chat surface, where it rests") - } - - // MARK: Helpers - - private func dashboardSource() throws -> String { - let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let dashboardURL = - testsURL - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") - // omi-test-quality: source-inspection -- static contract: DashboardPage stage-close and deferred-focus wiring lives in SwiftUI @State/@FocusState and cannot be driven without a booted view - return try String(contentsOf: dashboardURL, encoding: .utf8) - } - - private func methodBody(named name: String, in source: String) throws -> String { - guard let declaration = source.range(of: "private func \(name)(") else { - throw NSError(domain: "HomeStageCloseSemanticsTests", code: 1) - } - guard let openingBrace = source[declaration.upperBound...].firstIndex(of: "{") else { - throw NSError(domain: "HomeStageCloseSemanticsTests", code: 2) - } - - var depth = 0 - var cursor = openingBrace - while cursor < source.endIndex { - switch source[cursor] { - case "{": depth += 1 - case "}": - depth -= 1 - if depth == 0 { - return String(source[source.index(after: openingBrace)..<cursor]) - } - default: break - } - cursor = source.index(after: cursor) - } - throw NSError(domain: "HomeStageCloseSemanticsTests", code: 3) - } - - private func computedPropertyBody(named name: String, in source: String) throws -> String { - let pattern = #"private var \#(name): [^{]+\{([\s\S]*?)\n\s+\}"# - let regex = try NSRegularExpression(pattern: pattern) - let range = NSRange(source.startIndex..<source.endIndex, in: source) - let match = try XCTUnwrap(regex.firstMatch(in: source, range: range)) - let bodyRange = try XCTUnwrap(Range(match.range(at: 1), in: source)) - return String(source[bodyRange]) - } } diff --git a/desktop/macos/Desktop/Tests/InsightAssistantTelemetryTests.swift b/desktop/macos/Desktop/Tests/InsightAssistantTelemetryTests.swift index d5b235af7cc..d9714217cb2 100644 --- a/desktop/macos/Desktop/Tests/InsightAssistantTelemetryTests.swift +++ b/desktop/macos/Desktop/Tests/InsightAssistantTelemetryTests.swift @@ -58,6 +58,7 @@ final class InsightAssistantTelemetryTests: XCTestCase { title: "title", message: "message", assistantId: "insight", + kind: .insight, insightDeliveryID: deliveryID ) diff --git a/desktop/macos/Desktop/Tests/InterjectWiringTests.swift b/desktop/macos/Desktop/Tests/InterjectWiringTests.swift index 6a65f89dd7c..2571c875ee7 100644 --- a/desktop/macos/Desktop/Tests/InterjectWiringTests.swift +++ b/desktop/macos/Desktop/Tests/InterjectWiringTests.swift @@ -267,6 +267,7 @@ final class InterjectWiringTests: XCTestCase { title: "Insight", message: "Body", assistantId: "context-director", + kind: .insight, context: FloatingBarNotificationContext( sourceTitle: "Insight", assistantId: "context-director", diff --git a/desktop/macos/Desktop/Tests/JITProactivityDeliveryTests.swift b/desktop/macos/Desktop/Tests/JITProactivityDeliveryTests.swift index 2e7f283efe9..113db81bb2d 100644 --- a/desktop/macos/Desktop/Tests/JITProactivityDeliveryTests.swift +++ b/desktop/macos/Desktop/Tests/JITProactivityDeliveryTests.swift @@ -340,7 +340,13 @@ final class JITProactivityDeliveryTests: XCTestCase { return true }, authorizationCurrent: { _ in true }, authorizationSnapshotProvider: { authorization }) await recovered.installLifecycleRetry() - NotificationCenter.default.post(name: .runtimeOwnerDidChange, object: nil) + // On the main thread, as `performEffectiveOwnerTransition` posts it. Posting + // from this async test body instead delivered it straight onto a cooperative + // thread, where the first `@MainActor` observer to have been constructed by + // an earlier suite trapped on entry and took the whole test host with it. + await MainActor.run { + NotificationCenter.default.post(name: .runtimeOwnerDidChange, object: nil) + } for _ in 0..<20 { if await recovered.pendingCount(ownerID: "owner") == 0 { break } // omi-test-quality: wall-clock-wait -- lifecycle observer scheduling has no injectable clock diff --git a/desktop/macos/Desktop/Tests/OneChatShellRichBlockTests.swift b/desktop/macos/Desktop/Tests/OneChatShellRichBlockTests.swift new file mode 100644 index 00000000000..970e1fcd758 --- /dev/null +++ b/desktop/macos/Desktop/Tests/OneChatShellRichBlockTests.swift @@ -0,0 +1,218 @@ +import XCTest + +@testable import Omi_Computer + +/// One shell renders one transcript, and every content-block kind in it is a +/// control the reader can act on — on every surface, capability or not. +/// +/// The regression these tests exist for was silent by construction: six block +/// kinds were skipped during grouping unless the host passed an optional +/// capability context, so a turn whose only content was a task card rendered as +/// an *empty assistant reply* in the task panel and in the notch, and as a +/// tickable card in the main window. Nothing logged, nothing threw. +@MainActor +final class OneChatShellRichBlockTests: XCTestCase { + private func defaults() throws -> UserDefaults { + let suiteName = "OneChatShellRichBlockTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + return defaults + } + + /// A single assistant turn carrying prose plus all six interactable kinds. + private var everyRichBlockMessage: ChatMessage { + var message = ChatMessage(id: "assistant-1", text: "", sender: .ai) + message.contentBlocks = [ + .text(id: "text", text: "Here is what I found."), + .questionCard( + id: "question", questionId: "question-1", text: "Which one?", + subjectKind: "goal", subjectId: "goal-1", + options: [["optionId": "a", "label": "The first"], ["optionId": "b", "label": "The second"]] + ), + .taskCard(id: "task", taskId: "task-1"), + .goalLink(id: "goal", goalId: "goal-1", summary: "Ship the shell"), + .captureLink( + id: "capture", conversationId: "capture-1", momentTimestampMs: 4_000, summary: "Standup"), + .conversationLink( + id: "conversation", conversationId: "conversation-1", summary: "Design review", + recommendedActionItems: []), + .memoryLink(id: "memory", memoryId: "memory-1", summary: "Prefers mornings"), + ] + return message + } + + func testOneTurnYieldsEveryTypedGroupInTranscriptOrder() { + let groups = ContentBlockGroup.visibleChatGroups( + everyRichBlockMessage.contentBlocks, isStreaming: false) + + XCTAssertEqual( + groups.map(\.id), + ["text", "question", "task", "goal", "capture", "conversation", "memory"], + "grouping must preserve the transcript's order and drop nothing") + + var kinds: [String] = [] + for group in groups { + switch group { + case .text: kinds.append("text") + case .questionCard: kinds.append("questionCard") + case .taskCard: kinds.append("taskCard") + case .goalLink: kinds.append("goalLink") + case .captureLink: kinds.append("captureLink") + case .conversationLink: kinds.append("conversationLink") + case .memoryLink: kinds.append("memoryLink") + default: kinds.append("unexpected") + } + } + XCTAssertEqual( + kinds, + ["text", "questionCard", "taskCard", "goalLink", "captureLink", "conversationLink", "memoryLink"]) + } + + /// The same turn, grouped through the projection the notch and task panel use. + /// They call the identical entry point, so "the notch renders fewer kinds" is + /// no longer expressible. + func testStreamingAndSettledProjectionsBothKeepEveryRichBlock() { + let blocks = everyRichBlockMessage.contentBlocks + XCTAssertEqual(ContentBlockGroup.visibleChatGroups(blocks, isStreaming: true).count, 7) + XCTAssertEqual(ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false).count, 7) + } + + /// Each link block's tap lands on the typed focus its card promises, through + /// the context every host now carries. Recording is the navigation owner's own + /// published state, not a stubbed closure. + func testEveryLinkBlockActionReachesItsTypedNavigationTarget() throws { + let navigation = ChatFirstShellNavigation(defaults: try defaults()) + + navigation.open(focus: .task(id: "task-1")) + XCTAssertEqual(navigation.route, .tasks) + XCTAssertEqual(navigation.pendingFocus, .task(id: "task-1")) + + navigation.open(focus: .goal(id: "goal-1")) + XCTAssertEqual(navigation.route, .goals) + XCTAssertEqual(navigation.pendingFocus, .goal(id: "goal-1")) + + navigation.open(focus: .capture(id: "capture-1", momentTs: 4)) + XCTAssertEqual(navigation.route, .memories) + XCTAssertEqual(navigation.pendingFocus, .capture(id: "capture-1", momentTs: 4)) + + navigation.open(focus: .memory(id: "memory-1")) + XCTAssertEqual(navigation.route, .memories) + XCTAssertEqual(navigation.pendingFocus, .memory(id: "memory-1")) + + // The conversation link carries the exact fetched record rather than an id, + // and lands on the hub-owned Conversations destination. + let record = ChatFirstRichBlockTestConversation.make(id: "conversation-1") + navigation.open(conversation: record) + XCTAssertEqual(navigation.route, .memories) + XCTAssertEqual(navigation.pendingConversation?.id, "conversation-1") + XCTAssertNil(navigation.pendingFocus) + } + + /// A goal link resolves asynchronously; a newer link must win. This is the one + /// action with a fence, so it is asserted through the fence's own API. + func testGoalLinkResolutionFenceKeepsTheNewestRequest() throws { + let navigation = ChatFirstShellNavigation(defaults: try defaults()) + let stale = navigation.beginGoalLinkResolution() + let fresh = navigation.beginGoalLinkResolution() + + XCTAssertFalse(navigation.completeGoalLinkResolution(goalID: "goal-stale", generation: stale)) + XCTAssertNil(navigation.pendingFocus) + XCTAssertTrue(navigation.completeGoalLinkResolution(goalID: "goal-fresh", generation: fresh)) + XCTAssertEqual(navigation.pendingFocus, .goal(id: "goal-fresh")) + } + + // MARK: - Capability-off + + /// Capability-off must degrade, not disappear. The question card is the only + /// block whose *action* needs the server projection, so it is the only one that + /// changes — and it changes to "visible but unpressable", never to "hidden". + func testCapabilityOffDisablesQuestionOptionsWithoutHidingThem() { + let off = ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: false, isCapabilityAvailable: false, hasSelection: false, hasOptions: true) + XCTAssertEqual(off, .disabled) + XCTAssertTrue(off.isVisible, "a question with no visible answers explains nothing") + XCTAssertFalse(off.isPressable) + + let on = ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: true, isCapabilityAvailable: true, hasSelection: false, hasOptions: true) + XCTAssertEqual(on, .enabled) + XCTAssertTrue(on.isPressable) + } + + /// The two ways a live capability retires a question keep hiding its options: + /// it has been answered, or its turn is no longer the transcript's tail. + func testAnsweredOrRetiredQuestionStillHidesItsOptions() { + XCTAssertEqual( + ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: false, isCapabilityAvailable: true, hasSelection: true, hasOptions: true), + .hidden) + XCTAssertEqual( + ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: false, isCapabilityAvailable: true, hasSelection: false, hasOptions: true), + .hidden, + "a question that has lost the tail is history, not an offer") + XCTAssertEqual( + ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: true, isCapabilityAvailable: false, hasSelection: false, hasOptions: false), + .hidden) + } + + /// The task card is bound to `TasksStore`, so checking one off never consulted + /// the projection and must keep working with it absent. + func testTaskCardStaysActionableWithNoCapabilityProjection() { + var gate = ChatFirstMainChatProjectionGate() + XCTAssertTrue(gate.configure(sample: nil, ownerID: "owner-a")) + XCTAssertNil(gate.capability(for: .mainChat(chatId: nil), ownerID: "owner-a")) + + // Nothing in the task card's own presentation consults the gate: its display + // is derived from the store's record alone. + let task = TaskActionItem( + id: "task-1", description: "Ship it", completed: false, createdAt: Date(), + dueAt: nil, completedAt: nil, deleted: false) + XCTAssertEqual( + ChatFirstTaskCardPresentation.displayTask(liveTask: task, retainedCompletedTask: nil)?.id, + "task-1") + XCTAssertTrue( + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: true, + reconciledTask: TaskActionItem( + id: "task-1", description: "Ship it", completed: true, createdAt: Date(), + dueAt: nil, completedAt: Date(), deleted: false))) + } +} + +/// A minimal server record for the conversation-link assertions above. +enum ChatFirstRichBlockTestConversation { + static func make(id: String) -> ServerConversation { + ServerConversation( + id: id, + createdAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 1_001), + startedAt: Date(timeIntervalSince1970: 1_000), + finishedAt: Date(timeIntervalSince1970: 1_060), + structured: Structured( + title: "Design review", + overview: "Overview", + emoji: "", + category: "other", + actionItems: [], + events: [] + ), + transcriptSegments: [], + transcriptSegmentsIncluded: false, + geolocation: nil, + photos: [], + appsResults: [], + source: .desktop, + language: "en", + status: .completed, + discarded: false, + deleted: false, + isLocked: false, + starred: false, + folderId: nil, + inputDeviceName: nil, + deferred: false + ) + } +} diff --git a/desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift b/desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift new file mode 100644 index 00000000000..3340f63976d --- /dev/null +++ b/desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift @@ -0,0 +1,108 @@ +import XCTest + +@testable import Omi_Computer + +/// Every proactive card must say what it is before it can reach the transcript. +/// +/// `showNotification` used to take an optional `kind:` and `FloatingBarNotification` +/// filled it in from `assistantId`, whose default arm was `.general`. Five +/// producers never passed one, so their rows journaled a bare +/// `notification:<uuid>` key and came back badged "Notification" — a row that +/// says nothing about what Omi noticed. Nothing failed; the copy was simply +/// wrong forever. +final class ProactiveNotificationKindTests: XCTestCase { + /// Every assistant id a producer actually ships, mapped to what its card is. + /// `.general` is not reachable from any of them — it is decode-only. + private static let producerAssistantIDs = [ + "suggestion", "insight", "task", "memory-extraction", "goals", "meeting-notes", + "integration_connect", "context-director", "trial", "onboarding", + "notch_receipt", "notch_end", "reach_error", "unknown-future-assistant", + ] + + /// The transcript row a journaled notification comes back as. + private static func journaledRow(clientTurnId: String) -> ChatMessage { + ChatMessage(id: UUID().uuidString, clientTurnId: clientTurnId, text: "Body", sender: .ai) + } + + func testNoProducerAssistantIDDerivesTheDecodeOnlyGeneralKind() { + for assistantID in Self.producerAssistantIDs { + XCTAssertNotEqual( + ProactiveNotificationKind.from(assistantId: assistantID), .general, + "\(assistantID) would journal a bare notification:<uuid> key badged \"Notification\"") + } + for decisionType in ["suggest", "focus_nudge", "insight", "task_candidate", "resurface", ""] { + XCTAssertNotEqual(ProactiveNotificationKind.from(decisionType: decisionType), .general) + } + } + + func testEveryProducerKindJournalsAKindedContinuityKey() { + let id = UUID() + let bare = ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id) + + for kind in ProactiveNotificationKind.allCases where kind != .general { + let key = ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id, kind: kind) + XCTAssertNotEqual(key, bare, "\(kind.rawValue) must not journal the bare historical key") + XCTAssertTrue(key.hasPrefix("notification:\(kind.rawValue):")) + XCTAssertEqual( + ChatContinuityInvariants.proactiveNotificationKind(Self.journaledRow(clientTurnId: key)), kind, + "the kind must survive the round trip that renders the badge") + } + } + + /// Historical rows carry the bare key. Decoding must keep working, and it must + /// keep resolving to `.general` — that arm is the only reason `.general` exists. + func testHistoricalBareKeysStillDecode() { + let id = UUID() + let bare = ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id) + XCTAssertEqual(bare, "notification:\(id.uuidString)") + XCTAssertEqual( + ChatContinuityInvariants.proactiveNotificationKind(Self.journaledRow(clientTurnId: bare)), + .general) + XCTAssertEqual( + ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id, kind: .general), bare, + "the bare form stays reachable for round-tripping history, never for minting") + } + + /// Trial messaging and onboarding permission help are cards, not observations. + /// They present and dismiss; they must never become chat rows. + func testProductCopyCardsAreExcludedFromJournaling() { + XCTAssertFalse(ProactiveNotificationKind.trial.isJournaled) + XCTAssertFalse(ProactiveNotificationKind.onboarding.isJournaled) + for kind in ProactiveNotificationKind.allCases where kind != .trial && kind != .onboarding { + XCTAssertTrue(kind.isJournaled, "\(kind.rawValue) is something Omi observed") + } + } + + /// The five category toggles gate the five proactive categories and nothing + /// else. Functional notices and the two never-journaled cards stay ungated. + func testCategoryTogglesGateOnlyTheFiveProactiveCategories() { + func allows(_ kind: ProactiveNotificationKind) -> Bool { + NotificationService.categoryToggleAllows( + kind: kind, + focusEnabled: false, + taskEnabled: false, + insightEnabled: false, + memoryEnabled: false, + integrationEnabled: false, + meetingSummaryEnabled: false) + } + for gated: ProactiveNotificationKind in [ + .suggestion, .task, .meetingNotes, .insight, .resurface, .goal, .memory, .integration, + ] { + XCTAssertFalse(allows(gated), "\(gated.rawValue) must honour its category toggle") + } + for ungated: ProactiveNotificationKind in [.general, .functional, .trial, .onboarding] { + XCTAssertTrue(allows(ungated), "\(ungated.rawValue) sits outside the five-category taxonomy") + } + } + + /// Every kind still presents as one of the five user-facing badges (or the two + /// neutral ones), so a new kind cannot reach the transcript unlabelled. + func testEveryKindHasABadge() { + for kind in ProactiveNotificationKind.allCases { + let badge = ProactiveNotificationBadge(kind: kind) + XCTAssertFalse(badge.label.isEmpty, kind.rawValue) + XCTAssertFalse(badge.systemImage.isEmpty, kind.rawValue) + } + } +} diff --git a/desktop/macos/Desktop/Tests/QueryShellTests.swift b/desktop/macos/Desktop/Tests/QueryShellTests.swift index 01a006c0fdf..5e1bff2a5e1 100644 --- a/desktop/macos/Desktop/Tests/QueryShellTests.swift +++ b/desktop/macos/Desktop/Tests/QueryShellTests.swift @@ -8,35 +8,6 @@ import XCTest @MainActor final class QueryShellTests: XCTestCase { - func testHomeDesignSwitchReachesAllThreeHomePresentations() { - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: false, - useOldestHomeDesign: false, - forceModernPresentation: false), - .queryShell) - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: true, - useOldestHomeDesign: false, - forceModernPresentation: false), - .redesignedHub) - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: true, - useOldestHomeDesign: true, - forceModernPresentation: false), - .oldestLegacy) - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: true, - useOldestHomeDesign: true, - forceModernPresentation: true), - .queryShell) - } - - // MARK: - The one key - /// **`⏎` sends. There is nothing else for it to mean.** /// /// The surface used to answer this question with "it depends": `⏎` searched and `⌘⏎` asked, which diff --git a/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift b/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift index 2de49d178aa..93ace43a8e7 100644 --- a/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift @@ -45,10 +45,11 @@ final class ScreenRecordingPermissionPolicyTests: XCTestCase { // register-first helper (each of these files had an open-then-register path). for path in [ "Sources/MainWindow/Pages/PermissionsPage.swift", - "Sources/MainWindow/SidebarView.swift", "Sources/Rewind/UI/RewindPage.swift", - // DashboardPage's capture toggle now delegates to CaptureListeningLogic, - // which owns the register-first screen-recording grant. + // The legacy sidebar shell (SidebarView.swift) and DashboardPage were + // deleted with the one-chat-shell migration; ChatFirstShell's capture + // toggle now delegates to CaptureListeningLogic, which owns the + // register-first screen-recording grant. "Sources/MainWindow/CaptureListeningLogic.swift", // OmiApp's menu-bar toggle now delegates to SystemCaptureControls, which owns the // register-first screen-recording grant for both the menu bar and the notch cluster. @@ -63,9 +64,7 @@ final class ScreenRecordingPermissionPolicyTests: XCTestCase { // Negative guard: the register-after-open-Settings anti-pattern is gone. for path in [ "Sources/MainWindow/Pages/PermissionsPage.swift", - "Sources/MainWindow/SidebarView.swift", "Sources/Rewind/UI/RewindPage.swift", - "Sources/MainWindow/Pages/DashboardPage.swift", "Sources/MainWindow/CaptureListeningLogic.swift", ] { let src = try sourceFile(path) diff --git a/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift b/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift index 6a77e083fc7..cfba0eefdd6 100644 --- a/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift +++ b/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift @@ -229,7 +229,6 @@ final class ShellGlassChromeTests: XCTestCase { private static let shellSources = [ "MainWindow/GlassShellChrome.swift", "MainWindow/DesktopHomeView.swift", - "MainWindow/SidebarView.swift", "MainWindow/DesktopTopBar.swift", "MainWindow/ChatFirst/ChatFirstShell.swift", "MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift", diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 4fca2282838..d93cfdd5e4f 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -402,7 +402,7 @@ final class StartupWarmupPolicyTests: XCTestCase { let dashboardURL = testsURL .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") + .appendingPathComponent("Sources/MainWindow/Dashboard/DashboardViewModel.swift") let containerSource = try String(contentsOf: containerURL, encoding: .utf8) let dashboardSource = try String(contentsOf: dashboardURL, encoding: .utf8) diff --git a/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift b/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift index 5e2846c151f..060b6eb9a4b 100644 --- a/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift +++ b/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift @@ -4,7 +4,8 @@ import XCTest /// `TaskActionItem.isPendingSuggestion` still names AI-captured action items so /// proactive nudges can skip leftover extractor rows. Those rows are ordinary -/// due-date tasks on the Tasks page; Candidate review is a separate surface. +/// due-date tasks everywhere the user or the assistant reads the list; Candidate +/// review is a separate surface. final class TaskSuggestionTriageTests: XCTestCase { private func task( @@ -43,11 +44,15 @@ final class TaskSuggestionTriageTests: XCTestCase { XCTAssertFalse(task(source: "screenshot", deleted: true).isPendingSuggestion) } - func testDashboardLanesExcludeUnreviewedAICaptures() { - XCTAssertFalse(DashboardTaskLanePolicy.admits(task(source: "screenshot"))) - XCTAssertFalse(DashboardTaskLanePolicy.admits(task(source: "transcription:omi"))) - XCTAssertTrue(DashboardTaskLanePolicy.admits(task(source: "manual"))) - XCTAssertTrue(DashboardTaskLanePolicy.admits(task(source: "recurring"))) + /// The dashboard/realtime lanes used to drop these rows as "unreviewed". They + /// no longer do — capture is suggestion-only under INV-TASK-2, so a row that + /// reached `action_items` is already the user's, and hiding it only made the + /// assistant contradict the Tasks page. `DashboardTaskLaneReachTests` pins + /// that reach. The classification survives for proactive nudges, which is the + /// one consumer still asking "did a capture pipeline write this?". + func testTheClassificationSurvivesForProactiveNudgesOnly() { + XCTAssertTrue(task(source: "screenshot").isPendingSuggestion) + XCTAssertFalse(task(source: "manual").isPendingSuggestion) } } diff --git a/desktop/macos/Desktop/Tests/TasksStoreDeletedLaneRetirementTests.swift b/desktop/macos/Desktop/Tests/TasksStoreDeletedLaneRetirementTests.swift index bd9edaffef7..e57f6ff875b 100644 --- a/desktop/macos/Desktop/Tests/TasksStoreDeletedLaneRetirementTests.swift +++ b/desktop/macos/Desktop/Tests/TasksStoreDeletedLaneRetirementTests.swift @@ -18,8 +18,19 @@ import XCTest /// live tasks. Deleting a task on one device and then signing in on a new /// machine un-deleted it. /// -/// The lane is the authority on retirement, so `fetchDeletedPage` stamps it -/// rather than asking the projection to infer it. +/// The lane was therefore treated as the authority and `fetchDeletedPage` +/// stamped every row it returned. That went wrong in the other direction, and +/// far more expensively: `GET /v1/action-items` has no `deleted` parameter. +/// FastAPI drops the unknown query item and the handler's stream skips +/// soft-deleted documents outright, so the "deleted lane" answered with the +/// owner's *live* first page — and each visit to Removed tombstoned a hundred +/// live tasks in the local cache. Completing one of them from a chat task card +/// read the tombstone back and rendered "Task is no longer available" over the +/// task the reader had just ticked. +/// +/// So the contract is now the other way round: the lane keeps the rows the +/// response itself reports retired and drops the rest. Showing fewer rows in +/// Removed is a gap; manufacturing retirement is data loss. @MainActor private final class DeletedLaneProbe { var syncedPages: [[TaskActionItem]] = [] @@ -31,17 +42,21 @@ private final class DeletedLaneProbe { final class TasksStoreDeletedLaneRetirementTests: XCTestCase { - /// The regression: a deleted-lane row that carries neither legacy `deleted` - /// nor a recognized retired status must still reach the cache retired. + /// The regression this file now guards: a row the response reports as **live** + /// must not be written to the local cache retired, however it was fetched. + /// + /// This is the shape the real endpoint returns for every row, because it + /// ignores `deleted=true` entirely. Stamping it retired is what tombstoned + /// the owner's live tasks a page at a time. @MainActor - func testDeletedLaneRowIsRetiredBeforeItReachesTheLocalCache() async throws { + func testLiveRowFromTheDeletedLaneIsNeverTombstonedLocally() async throws { let store = TasksStore.shared await prepareStore(store) - let serverRow = task(id: "deleted-on-phone", taskStatus: "active") + let serverRow = task(id: "still-open", taskStatus: "active") XCTAssertFalse( serverRow.isRetired, - "fixture must reproduce the server shape that made this bug: retired by lane, live by projection") + "fixture must reproduce what the endpoint actually answers with: an ordinary live task") let probe = DeletedLaneProbe() let operations = TasksStore.OwnerBoundOperations( @@ -54,15 +69,16 @@ final class TasksStoreDeletedLaneRetirementTests: XCTestCase { await store.loadDeletedTasks(operations: operations) - let synced = try XCTUnwrap(probe.syncedPages.first, "the deleted page must be synced to the cache") - XCTAssertEqual(synced.map(\.id), ["deleted-on-phone"]) + let synced = probe.syncedPages.first ?? [] + XCTAssertTrue( + synced.allSatisfy { !$0.isRetired }, + "a live row must reach the cache live — writing it retired is what took the owner's tasks away") + XCTAssertTrue( + probe.cache.isEmpty, + "nothing may be tombstoned locally on the strength of the lane it was fetched from") XCTAssertTrue( - synced.allSatisfy { $0.isRetired }, - "a row from the deleted lane must never be written to the local cache as live — that is the resurrection") - XCTAssertEqual( - store.deletedTasks.map(\.id), - ["deleted-on-phone"], - "the retired row must show up in the deleted list instead of vanishing from every surface") + store.deletedTasks.isEmpty, + "Removed showing nothing is the honest answer here; showing live tasks is not") } /// A row the server already marked retired keeps its own marker: the lane diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 473e33572ac..08ba26fc69d 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -712,7 +712,13 @@ export function updateJournalTurn(store: AgentStore, input: UpdateJournalTurnInp const contentBlocks = input.replaceContentBlocks === undefined ? mergeById(current.contentBlocks, validateContentBlocks(input.appendContentBlocks ?? [])) - : mergeById([], validateContentBlocks(input.replaceContentBlocks)); + : mergeById( + [], + projectContentBlocksOverKernelAuthored( + current.contentBlocks, + validateContentBlocks(input.replaceContentBlocks), + ), + ); const resources = input.replaceResources === undefined ? mergeById(current.resources, validateResources(input.appendResources ?? [])) : mergeById([], validateResources(input.replaceResources)); @@ -1711,7 +1717,7 @@ export function terminalizeJournalTurn( } const content = input.content ?? current.content; const finalContentBlocks = input.disposition === "accept" && contentBlocks !== undefined - ? monotonicAcceptContentBlocks(current.contentBlocks, contentBlocks) + ? projectContentBlocksOverKernelAuthored(current.contentBlocks, contentBlocks) : contentBlocks ?? current.contentBlocks; const finalResources = input.disposition === "accept" && resources !== undefined ? monotonicAcceptResources(current.resources, resources) @@ -1974,19 +1980,62 @@ function markDiscardedBackendProjection(store: AgentStore, turnId: string, nowMs ); } -function monotonicAcceptContentBlocks( +/** + * The block kinds the kernel writes and the visible projection never authors. + * + * Both the streaming update and the terminal commit hand us the projection + * Swift assembled from the adapter stream — text, tool calls, thinking, and the + * cards Swift itself appends. A block the *agent* rendered mid-turn through + * `render_chat_blocks` cannot be in it: that append is a journal mutation, not + * a stream event, so the surface has never seen it. Replacing the turn's blocks + * with that projection deleted every task card, goal link and memory link the + * turn had rendered, seconds after the tool reported success — which is why + * chat-first components looked like they never rendered while the tool returned + * `ok`. + */ +const KERNEL_AUTHORED_CONTENT_BLOCK_TYPES: ReadonlySet<ConversationContentBlock["type"]> = new Set([ + "agentSpawn", + "agentCompletion", + "taskCard", + "goalLink", + "captureLink", + "conversationLink", + "memoryLink", + "questionCard", +]); + +/** + * Blocks the surface may re-send but never re-derive, so the journal's copy + * stays canonical even when a projection carries one of its own. + */ +const PINNED_CONTENT_BLOCK_TYPES: ReadonlySet<ConversationContentBlock["type"]> = new Set([ + "agentSpawn", + "agentCompletion", +]); + +/** + * Apply a surface projection over the journal's blocks without losing the ones + * the surface could not have known about. + * + * An id the projection carries is the projection's to define — that is how a + * question card's options get retired — except for the pinned kinds above. + * An id it omits survives only when the kernel wrote it. + */ +function projectContentBlocksOverKernelAuthored( current: readonly ConversationContentBlock[], incoming: readonly ConversationContentBlock[], ): ConversationContentBlock[] { - const protectedCurrent = new Map( + const pinned = new Map( current - .filter((block) => block.type === "agentSpawn" || block.type === "agentCompletion") + .filter((block) => PINNED_CONTENT_BLOCK_TYPES.has(block.type)) .map((block) => [block.id, block] as const), ); - const result = incoming.map((block) => structuredClone(protectedCurrent.get(block.id) ?? block)); + const result = incoming.map((block) => structuredClone(pinned.get(block.id) ?? block)); const resultIds = new Set(result.map((block) => block.id)); - for (const block of protectedCurrent.values()) { - if (!resultIds.has(block.id)) result.push(structuredClone(block)); + for (const block of current) { + if (resultIds.has(block.id)) continue; + if (!KERNEL_AUTHORED_CONTENT_BLOCK_TYPES.has(block.type)) continue; + result.push(structuredClone(block)); } return result; } diff --git a/desktop/macos/agent/src/runtime/kernel-core.ts b/desktop/macos/agent/src/runtime/kernel-core.ts index c3ad2b447af..16cf5de5121 100644 --- a/desktop/macos/agent/src/runtime/kernel-core.ts +++ b/desktop/macos/agent/src/runtime/kernel-core.ts @@ -151,7 +151,14 @@ function runtimeAdapterMetadata(input: ExecuteAgentRunInput, session: AgentSessi ...(input.metadata ?? {}), executionRole: session.executionRole, providerBoundary: session.providerBoundary, - surfaceKind: session.surfaceKind, + // The run's surface, not the session's. One shell means main Chat and the + // floating bar project the same conversation, so a session first registered + // by the floating bar keeps `surface_kind = floating_chat` while main-Chat + // runs execute on it. Every chat-first gate downstream — the pi-mono env, + // `effectiveChatFirstCapability`, the tool projection — admits `main_chat` + // only, so stamping the session's surface here told them a main-Chat turn + // was a floating one and the model was never offered `render_chat_blocks`. + surfaceKind: input.surfaceKind || session.surfaceKind, chatFirstUi: input.admittedContextSnapshot?.capabilities.chatFirstUi === true, chatFirstControlGeneration: input.admittedContextSnapshot?.capabilities.chatFirstControlGeneration ?? null, @@ -166,6 +173,7 @@ import { import type { ToolInvocationIdentity } from "./tool-invocation-ledger.js"; import { normalizeOmiToolName } from "./omi-tool-manifest.js"; import { routeExternalSurfaceTool } from "./external-surface-tool-policy.js"; +import type { ChatFirstCapabilityProjection } from "./chat-first-capability.js"; import { applyExecutionProfileToSession, readSessionExecutionProfile, @@ -198,9 +206,30 @@ export class KernelCore { protected readonly bindingResolutionLocks = new Map<string, Promise<void>>(); protected readonly contextDeliveryByBinding = new Map<string, ContextDeliveryCursor>(); protected readonly toolCapabilities: RunToolCapabilityBroker; + /** + * The one immutable server-derived Main Chat sample for this process, keyed + * `ownerId:sessionId`. Process-local only: never back this with SQLite or a + * user preference. + * + * It lives on the base class because *run admission* needs it, not only + * session resolution. A run that builds its own context snapshot without it + * projects a capability-off tool surface, and the adapter metadata derived + * from that snapshot is what decides whether the model is offered + * `render_chat_blocks` at all. + */ + protected readonly chatFirstCapabilities = new Map<string, ChatFirstCapabilityProjection>(); private transactionDepth = 0; private pendingSubscriberEvents: AgentEvent[] = []; + protected chatFirstCapability( + sessionId: string, + ownerId: string, + surfaceKind?: string + ): ChatFirstCapabilityProjection | undefined { + if (surfaceKind !== "main_chat") return undefined; + return this.chatFirstCapabilities.get(`${ownerId}:${sessionId}`); + } + constructor(options: AgentRuntimeKernelOptions) { this.store = options.store; this.registry = options.registry; @@ -819,6 +848,12 @@ export class KernelCore { session.ownerId, Date.now(), input.surfaceKind, + // Main Chat runs arrive with no client-supplied snapshot, so this + // branch builds every one of them. Dropping the capability here + // made the run's own snapshot say capability-off however the shell + // had resolved it, and that snapshot is what + // `runtimeAdapterMetadata` hands the adapter. + this.chatFirstCapability(session.sessionId, session.ownerId, input.surfaceKind), ); const expectationCount = [ input.expectedContextSnapshotVersion, @@ -865,6 +900,12 @@ export class KernelCore { prompt: input.prompt, producingTurnId: input.producingTurnId ?? null, metadata: input.metadata ?? {}, + // The surface this run was admitted for, which is not always the one + // its session was first registered under: one shell means main Chat + // and the floating bar share a session. Recorded here because the + // tool-capability broker has to gate on the run, and the session row + // is the wrong authority for that. + surfaceKind: input.surfaceKind, contextSnapshotVersion: contextSnapshot.version, contextSnapshotGeneration: contextSnapshot.snapshotGeneration, contextRendererFingerprint: contextSnapshot.rendererFingerprint, diff --git a/desktop/macos/agent/src/runtime/kernel-sessions.ts b/desktop/macos/agent/src/runtime/kernel-sessions.ts index 4517a704a0c..60955d1e92d 100644 --- a/desktop/macos/agent/src/runtime/kernel-sessions.ts +++ b/desktop/macos/agent/src/runtime/kernel-sessions.ts @@ -147,13 +147,6 @@ import { conversationIdForSession } from "./conversation-turns.js"; import type { AuthorizedRunToolInvocation } from "./run-tool-capability.js"; export class KernelSessions extends KernelArtifacts { - /** Process-local only: never back this with SQLite or a user preference. */ - private readonly chatFirstCapabilities = new Map<string, ChatFirstCapabilityProjection>(); - - private chatFirstCapability(sessionId: string, ownerId: string, surfaceKind?: string): ChatFirstCapabilityProjection | undefined { - if (surfaceKind !== "main_chat") return undefined; - return this.chatFirstCapabilities.get(`${ownerId}:${sessionId}`); - } ownedSession(sessionId: string, ownerId: string): AgentSession { const session = this.readSession(sessionId); this.assertSessionOwner(session, ownerId); diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index 3d9fcce2f0a..064aa11a70e 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -347,16 +347,16 @@ const swiftToolSurfacePatches: Record<string, OmiToolSurfacePatch> = { surfaces: ["realtime_voice"], capabilityDoc: doc( "Get Tasks", - "Read the user's overdue and due-today tasks locally.", + "Read the user's open tasks locally: overdue, due today, and undated.", [ "Use for plain voice questions like what are my tasks, what's due today, or what's on my list.", - "Prefer get_action_items for completed tasks, date ranges, or the full list.", + "Prefer get_action_items for completed tasks or an explicit date range.", ], ), executor: { kind: "swiftTool", executorName: "realtimeHub" }, voice: { realtimeDescription: - "Read the user's tasks (overdue + due today) locally and get them back as text to speak. Fast synchronous read — use this for 'what are my tasks', 'what's due today', 'what's on my list'. Reading tasks is always a direct call, never background work.", + "Read the user's open tasks locally and get them back as text to speak: everything overdue, everything due today, and everything on the list with no due date. This is the same list the Tasks page shows, so an empty result means the user genuinely has no open tasks — never say they have none without calling this first. Fast synchronous read — use it for 'what are my tasks', 'what's due today', 'what's on my list', 'what should I work on'. Reading tasks is always a direct call, never background work.", }, }, complete_task: { @@ -537,13 +537,13 @@ const swiftToolSurfacePatches: Record<string, OmiToolSurfacePatch> = { "Get Action Items", "Retrieve the user's tasks with optional completion and due-date filters.", [ - "Use for completed tasks, date ranges, or the full task list.", - "For voice, prefer get_tasks for plain overdue/due-today questions.", + "Use for completed tasks or an explicit date range.", + "For voice, prefer get_tasks for any plain question about the open list.", ], ), voice: { realtimeDescription: - "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish'), a DATE RANGE ('what's due next week'), or the FULL list ('all my tasks') — for plain 'what's due today / overdue', prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", + "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish') or a DATE RANGE ('what's due next week') — for any plain question about the open list, prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", }, }, create_action_item: { @@ -1157,6 +1157,11 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ label: "Get Conversations", description: "Retrieve user conversations with summaries, action items, metadata. Use for time-based queries or recaps.", promptSnippet: "get_conversations - Retrieve conversations by date range", + promptGuidelines: [ + "If the user asked to see, find, open, pick or choose a conversation — 'show me the call with Paul', 'which one was most interesting', 'find the meeting about pricing' — the conversation is the answer: render it as a captureLink block ({type:'captureLink', conversationId:'<canonical id from this result>', summary:'...'}) with render_chat_blocks, and keep the prose to one lead-in line. Do not answer with a bold title and a citation number in place of the component.", + "A follow-up that narrows an earlier result — 'pick one', 'the second one', 'tell me more about that one' — still renders the component for what it picks.", + "A recap of a day, a summary, a comparison, a count, or a list longer than three is prose that cites the conversations inline instead.", + ], latency: "fast network", inputSchema: schema({ start_date: { type: "string", description: "ISO date with timezone" }, @@ -1178,6 +1183,10 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ label: "Search Conversations", description: "Search conversations by topic or exact canonical ID/share link.", promptSnippet: "search_conversations - Find conversations about a topic or exact ID/share link", + promptGuidelines: [ + "If the user asked to find, see, open or pick a conversation, the match is the answer: render it as a captureLink block ({type:'captureLink', conversationId:'<canonical id from this result>', summary:'...'}) with render_chat_blocks and keep the prose to one lead-in line. Up to three matches render; say how many more there are.", + "When the conversation is only evidence for something you are answering in prose — what was decided, whether it happened, what someone said — cite it inline and render nothing.", + ], latency: "fast network", inputSchema: schema( { @@ -1202,6 +1211,10 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ label: "Get Memories", description: "Retrieve user memories - facts, preferences, habits. Use for 'what do you know about me?' type questions.", promptSnippet: "get_memories - Retrieve stored facts and preferences", + promptGuidelines: [ + "If the user asked to see, review, find or pick specific memories, the memories are the answer: render the ones that matter as memoryLink blocks ({type:'memoryLink', memoryId:'<id from this result>', summary:'...'}) with render_chat_blocks — a count in prose, never a bulleted copy of the cards.", + "'What do you know about me' and other summaries, comparisons or long lists answer in prose and cite the memories inline instead.", + ], latency: "fast network", inputSchema: schema({ limit: { type: "number", description: "Default 50" }, @@ -1222,6 +1235,10 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ label: "Search Memories", description: "Semantic search across user memories. Find memories about a topic using AI embeddings.", promptSnippet: "search_memories - Find memories about a topic", + promptGuidelines: [ + "If the user asked to find, see or pick a memory, the match is the answer: render up to three as memoryLink blocks ({type:'memoryLink', memoryId:'<id from this result>', summary:'...'}) with render_chat_blocks and keep the prose to one lead-in line.", + "When a memory is only evidence for an answer in prose, cite it inline and render nothing.", + ], latency: "fast network", inputSchema: schema( { @@ -1476,6 +1493,10 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ label: "Get Action Items", description: "Retrieve user tasks from Omi backend. Filter by completion status or due date.", promptSnippet: "get_action_items - Retrieve tasks", + promptGuidelines: [ + "If the user asked to see, review, pick from or work through their tasks, the tasks are the answer: render the few that matter as taskCard blocks with render_chat_blocks. Say how many there are in total — a count, never their names. Naming them in the message, as a list or as bullets, prints every card twice: once as words that cannot be ticked off and once as the card itself.", + "If a task is only evidence for something you are answering in prose — how many are open, whether one exists, what a day contained — cite it inline and render nothing.", + ], latency: "fast network", inputSchema: schema({ limit: { type: "number" }, @@ -2104,7 +2125,7 @@ export const chatFirstToolManifest: OmiToolManifestEntry[] = [ promptSnippet: "get_canonical_goals - Retrieve canonical goals with IDs for native goal links", promptGuidelines: [ "For goal questions, call this before answering and use only returned canonical goals.", - "Render every returned goal the user should act on as a goalLink in the same response.", + "Render a goalLink only for a goal this turn is actually about — the one the user asked for or just changed. Goals you merely read to answer a question are citations.", "If it returns no goals, state that plainly; do not infer goals from memories or local SQL.", ], latency: "fast network", @@ -2123,13 +2144,17 @@ export const chatFirstToolManifest: OmiToolManifestEntry[] = [ { name: "render_chat_blocks", label: "Render Chat Blocks", - description: "Render native, interactive Omi components on the producing main Chat turn. In Chat-first UI, call this in the same turn whenever you retrieve, create, or summarize tasks, goals, memories, or captured conversations; do not leave those entities as a Markdown table/list or ask whether the user wants cards. For taskCard, taskId MUST be the opaque canonical ID returned by get_action_items or create_action_item; never use a local SQLite/execute_sql numeric row ID. If another lookup found task text, call get_action_items before rendering. Supported shapes include {type:'taskCard', taskId:'...'}, {type:'goalLink', goalId:'...', summary:'...'}, {type:'memoryLink', memoryId:'...', summary:'...'}, and {type:'captureLink', conversationId:'...', summary:'...'}.", - promptSnippet: "render_chat_blocks - Render native interactive Omi components in this main Chat response; use by default for entity results", + description: "Render native, interactive Omi components on the producing main Chat turn. Use it when the entity IS the answer — the user asked to see or act on that task, goal, memory or conversation, or this turn created or changed one — so the next thing they do is click it. Do NOT use it for entities you merely read to answer in prose: those are sources, and sources belong in citations. Most turns need no components at all. Render at most three. The components ARE the list: when you render them, the message text must be at most one short lead-in sentence, and must never be a numbered or bulleted list repeating what the components already show. For taskCard, taskId MUST be the opaque canonical ID returned by get_action_items or create_action_item; never use a local SQLite/execute_sql numeric row ID. If another lookup found task text, call get_action_items before rendering. Supported shapes include {type:'taskCard', taskId:'...'}, {type:'goalLink', goalId:'...', summary:'...'}, {type:'memoryLink', memoryId:'...', summary:'...'}, and {type:'captureLink', conversationId:'...', summary:'...'}.", + promptSnippet: "render_chat_blocks - Render a native interactive Omi component when the entity is what the user asked for or acted on; cite sources in prose otherwise", promptGuidelines: [ - "After reading or mutating tasks, goals, memories, or captured conversations, render the relevant native components before finishing the same response.", - "Do not ask whether the user wants cards and do not substitute Markdown tables or lists for entities that have canonical IDs.", + "Default to a component whenever the user asks for something Omi draws natively — a task, goal, memory, conversation or capture. Prose wins only when the request is to read rather than to open or act on the thing: a summary, a recap, an analysis, a comparison, a count, or a list too long to render. 'Pick one', 'show me', 'which one', 'find the one about X', 'my tasks for today' all want the component, and a bold title with a citation number is not a substitute for it.", + "Render a component when the entity is the point of the turn: the user asked to see or act on it, or this turn created, completed, or changed it.", + "Rendering replaces the writing. \"Here are your three tasks:\" followed by three task cards is right; the same sentence followed by a numbered list of those same three tasks, with or without cards, is the failure this rule exists to stop.", + "Answering a question from what you read is the common case and needs no components. Cite those entities inline instead — a summary of yesterday cites the conversations it drew on, it does not stack cards above itself.", + "Render at most three components in a turn, and prefer none to a wall of them.", + "The cap is not a reason to fall back to prose. When the user asked to see or work through their tasks, goals or memories, render the three that matter and say how many more there are — a numbered list of entities written out in the message is the exact thing components replace.", + "Do not ask whether the user wants cards, and do not substitute a Markdown table for entities the user asked to act on.", "For task cards, obtain opaque canonical task IDs from get_action_items or create_action_item; execute_sql numeric row IDs are invalid.", - "Use only for a compact actionable question, task, goal, memory, or Omi-device capture reference.", "Never invent entity identifiers or URLs; the server validates every requested reference.", ], latency: "fast network", diff --git a/desktop/macos/agent/src/runtime/run-tool-capability.ts b/desktop/macos/agent/src/runtime/run-tool-capability.ts index 181b540eef8..b0d86993754 100644 --- a/desktop/macos/agent/src/runtime/run-tool-capability.ts +++ b/desktop/macos/agent/src/runtime/run-tool-capability.ts @@ -773,7 +773,15 @@ export class RunToolCapabilityBroker { && !Array.isArray(admitted.capabilities) ? admitted.capabilities as Record<string, unknown> : {}; - const chatFirstUi = admittedCapabilities.chatFirstUi === true && text(row.surface_kind) === "main_chat"; + // The run's surface, falling back to the session's for runs admitted before + // it was recorded. `s.surface_kind` is where the session was first + // registered — for a shared shell that can be `floating_chat` while main + // Chat runs on it, and gating chat-first on that rejected the tool the + // model had just been offered. + const runSurfaceKind = typeof runInput.surfaceKind === "string" && runInput.surfaceKind.trim() + ? runInput.surfaceKind.trim() + : text(row.surface_kind); + const chatFirstUi = admittedCapabilities.chatFirstUi === true && runSurfaceKind === "main_chat"; const controlGeneration = Number(admittedCapabilities.chatFirstControlGeneration); return { ownerId: text(row.owner_id), @@ -782,7 +790,10 @@ export class RunToolCapabilityBroker { attemptStatus: text(row.authoritative_attempt_status) as AttemptStatus, currentAttemptId: text(latest.attempt_id), profile: this.profileForSession(sessionId), - surfaceKind: externalSurface?.authority === "swift_realtime" ? "realtime_voice" : text(row.surface_kind), + // Also the run's surface: Swift re-validates an authorized invocation with + // `surfaceKind == "main_chat"` before it will execute a chat-first tool, + // and selects the manifest digest from the same field. + surfaceKind: externalSurface?.authority === "swift_realtime" ? "realtime_voice" : runSurfaceKind, externalRefKind: row.external_ref_kind === null ? null : text(row.external_ref_kind), externalRefId: row.external_ref_id === null ? null : text(row.external_ref_id), originatingUserText: typeof runInput.prompt === "string" ? runInput.prompt : "", diff --git a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts index 4644c138527..504c58bf01e 100644 --- a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts +++ b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts @@ -56,6 +56,153 @@ describe("chat-first admitted capability projection", () => { store.close(); }); + /// The desktop never sends `admittedContextSnapshot` — it is an internal + /// kernel field, absent from the wire protocol — so every real Main Chat run + /// takes the branch that builds its own snapshot. That branch dropped the + /// capability, and the adapter metadata is derived from that snapshot, so the + /// spawned model was offered the 41 base tools and never `render_chat_blocks`. + /// Every existing test here passed a snapshot in by hand and so never + /// exercised the path the product uses. + it("projects the capability onto a run that arrives without a context snapshot", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "no-snapshot" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "no-snapshot", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "no-snapshot-client", + prompt: "Show me three open tasks I could pick up right now.", + cwd: "/tmp/chat-first-no-snapshot", + requestId: "no-snapshot-request-1", + }); + + expect(adapter.opened[0]?.metadata).toMatchObject({ + surfaceKind: "main_chat", + chatFirstUi: true, + chatFirstControlGeneration: 7, + }); + store.close(); + }); + + /// One shell: main Chat and the floating bar project the same conversation, + /// so the session row can carry `floating_chat` while a main-Chat run executes + /// on it. Every chat-first gate admits `main_chat` only, so the adapter has to + /// be told the run's surface, not the session's registration. + it("stamps the run surface on a session the floating bar registered first", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "floating_chat", externalRefKind: "chat", externalRefId: "shared-shell" }, + defaultAdapterId: "acp", + }); + kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "shared-shell" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 0 }, + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "shared-shell", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "shared-shell-client", + prompt: "List three of my open tasks.", + cwd: "/tmp/chat-first-shared-shell", + requestId: "shared-shell-request-1", + }); + + expect(adapter.opened[0]?.metadata).toMatchObject({ surfaceKind: "main_chat" }); + store.close(); + }); + + /// The tool being advertised is not the same as the tool being allowed. The + /// broker re-checks the surface when the model actually calls it, and it read + /// the session's registration rather than the run's surface — so a shared + /// shell offered `render_chat_blocks` and then answered + /// `tool_not_allowed: Tool is unavailable for this run execution profile`. + it("allows the chat-first tools on a main-Chat run of a floating-registered session", async () => { + const { store, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "floating_chat", externalRefKind: "chat", externalRefId: "shared-allow" }, + defaultAdapterId: "acp", + }); + kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "shared-allow" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 0 }, + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "shared-allow", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "shared-allow-client", + prompt: "Render my tasks as cards.", + cwd: "/tmp/chat-first-shared-allow", + requestId: "shared-allow-request-1", + }); + + const runRow = store.getRow( + "SELECT input_json FROM runs WHERE session_id = ? ORDER BY rowid DESC LIMIT 1", + [resolved.agentSessionId], + ); + const runInput = JSON.parse(String(runRow.input_json)); + expect(runInput.surfaceKind).toBe("main_chat"); + expect(runInput.admittedContextSnapshot.capabilities.allowedToolNames).toContain("render_chat_blocks"); + store.close(); + }); + + it("leaves a run capability-off when the shell never sampled one", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "unsampled" }, + defaultAdapterId: "acp", + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "unsampled", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "unsampled-client", + prompt: "Anything.", + cwd: "/tmp/chat-first-unsampled", + requestId: "unsampled-request-1", + }); + + expect(adapter.opened[0]?.metadata).toMatchObject({ + surfaceKind: "main_chat", + chatFirstUi: false, + chatFirstControlGeneration: null, + }); + store.close(); + }); + it("preserves the enabled main-Chat generation through run admission for both dynamic tools", async () => { const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); const resolved = kernel.resolveSurfaceSession({ diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 9a67c6bfab1..db1d6072265 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -503,6 +503,82 @@ describe("kernel conversation journal", () => { fixture.store.close(); }); + it("keeps the cards the agent rendered when the surface terminalizes its own projection", () => { + // The live failure this pins: `render_chat_blocks` appended three task + // cards, the tool answered `ok`, and about three seconds later the turn + // terminalized with the projection Swift built from the adapter stream — + // text and tool calls, and no card, because the append was a journal + // mutation the surface never saw. The replace then deleted all three. + const fixture = newSurface("main_chat", "chat", "chat-first-survives-terminal"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-survives-terminal"); + recordStreamingAssistantPlaceholder(fixture, "turn-chat-first-survives"); + const cards: ConversationContentBlock[] = [ + { type: "taskCard", id: "cfb-task-1", taskId: "task-1" }, + { type: "goalLink", id: "cfb-goal-1", goalId: "goal-1", summary: "Ship the desktop beta" }, + { type: "memoryLink", id: "cfb-memory-1", memoryId: "memory-1", summary: "Prefers morning reviews" }, + ]; + appendChatFirstBlocksToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + blocks: cards, + }); + + fixture.store.execute("UPDATE runs SET status = 'succeeded' WHERE run_id = ?", [run.runId]); + fixture.store.execute("UPDATE run_attempts SET status = 'succeeded' WHERE attempt_id = ?", [attempt.attemptId]); + const surfaceProjection: ConversationContentBlock[] = [ + { type: "text", id: "turn-chat-first-survives:terminal", text: "Here are your three most urgent tasks." }, + ]; + const terminalized = terminalizeJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: "turn-chat-first-survives", + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + disposition: "accept", + content: "Here are your three most urgent tasks.", + replaceContentBlocks: surfaceProjection, + nowMs: 20, + }); + + expect(terminalized.contentBlocks).toEqual([...surfaceProjection, ...cards]); + fixture.store.close(); + }); + + it("keeps the cards the agent rendered when the surface replaces its blocks mid-turn", () => { + // Terminalization is not the only replace. The streaming projection pushes + // the surface's own block list several times a turn, and each one used to + // take the agent's cards with it — the append survived the commit and died + // to the very next update. + const fixture = newSurface("main_chat", "chat", "chat-first-survives-update"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-survives-update"); + recordStreamingAssistantPlaceholder(fixture, "turn-chat-first-update"); + appendChatFirstBlocksToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + blocks: [{ type: "taskCard", id: "cfb-task-1", taskId: "task-1" }], + }); + + const updated = updateJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: "turn-chat-first-update", + replaceContentBlocks: [ + { type: "text", id: "turn-chat-first-update:terminal", text: "Here they are." }, + ], + nowMs: 30, + }); + + expect(updated.contentBlocks).toEqual([ + { type: "text", id: "turn-chat-first-update:terminal", text: "Here they are." }, + { type: "taskCard", id: "cfb-task-1", taskId: "task-1" }, + ]); + fixture.store.close(); + }); + it("attaches only a ready local generated image to the producing Chat-first turn", () => { const fixture = newSurface("main_chat", "chat", "chat-first-evidence"); const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-evidence"); diff --git a/desktop/macos/agent/tests/fixtures/tool-manifest.json b/desktop/macos/agent/tests/fixtures/tool-manifest.json index 8e4ee5e4d22..8f5e51faf4a 100644 --- a/desktop/macos/agent/tests/fixtures/tool-manifest.json +++ b/desktop/macos/agent/tests/fixtures/tool-manifest.json @@ -3680,6 +3680,11 @@ "label": "Get Conversations", "description": "Retrieve user conversations with summaries, action items, metadata. Use for time-based queries or recaps.", "promptSnippet": "get_conversations - Retrieve conversations by date range", + "promptGuidelines": [ + "If the user asked to see, find, open, pick or choose a conversation — 'show me the call with Paul', 'which one was most interesting', 'find the meeting about pricing' — the conversation is the answer: render it as a captureLink block ({type:'captureLink', conversationId:'<canonical id from this result>', summary:'...'}) with render_chat_blocks, and keep the prose to one lead-in line. Do not answer with a bold title and a citation number in place of the component.", + "A follow-up that narrows an earlier result — 'pick one', 'the second one', 'tell me more about that one' — still renders the component for what it picks.", + "A recap of a day, a summary, a comparison, a count, or a list longer than three is prose that cites the conversations inline instead." + ], "latency": "fast network", "inputSchema": { "type": "object", @@ -3763,6 +3768,10 @@ "label": "Search Conversations", "description": "Search conversations by topic or exact canonical ID/share link.", "promptSnippet": "search_conversations - Find conversations about a topic or exact ID/share link", + "promptGuidelines": [ + "If the user asked to find, see, open or pick a conversation, the match is the answer: render it as a captureLink block ({type:'captureLink', conversationId:'<canonical id from this result>', summary:'...'}) with render_chat_blocks and keep the prose to one lead-in line. Up to three matches render; say how many more there are.", + "When the conversation is only evidence for something you are answering in prose — what was decided, whether it happened, what someone said — cite it inline and render nothing." + ], "latency": "fast network", "inputSchema": { "type": "object", @@ -3846,6 +3855,10 @@ "label": "Get Memories", "description": "Retrieve user memories - facts, preferences, habits. Use for 'what do you know about me?' type questions.", "promptSnippet": "get_memories - Retrieve stored facts and preferences", + "promptGuidelines": [ + "If the user asked to see, review, find or pick specific memories, the memories are the answer: render the ones that matter as memoryLink blocks ({type:'memoryLink', memoryId:'<id from this result>', summary:'...'}) with render_chat_blocks — a count in prose, never a bulleted copy of the cards.", + "'What do you know about me' and other summaries, comparisons or long lists answer in prose and cite the memories inline instead." + ], "latency": "fast network", "inputSchema": { "type": "object", @@ -3922,6 +3935,10 @@ "label": "Search Memories", "description": "Semantic search across user memories. Find memories about a topic using AI embeddings.", "promptSnippet": "search_memories - Find memories about a topic", + "promptGuidelines": [ + "If the user asked to find, see or pick a memory, the match is the answer: render up to three as memoryLink blocks ({type:'memoryLink', memoryId:'<id from this result>', summary:'...'}) with render_chat_blocks and keep the prose to one lead-in line.", + "When a memory is only evidence for an answer in prose, cite it inline and render nothing." + ], "latency": "fast network", "inputSchema": { "type": "object", @@ -4537,6 +4554,10 @@ "label": "Get Action Items", "description": "Retrieve user tasks from Omi backend. Filter by completion status or due date.", "promptSnippet": "get_action_items - Retrieve tasks", + "promptGuidelines": [ + "If the user asked to see, review, pick from or work through their tasks, the tasks are the answer: render the few that matter as taskCard blocks with render_chat_blocks. Say how many there are in total — a count, never their names. Naming them in the message, as a list or as bullets, prints every card twice: once as words that cannot be ticked off and once as the card itself.", + "If a task is only evidence for something you are answering in prose — how many are open, whether one exists, what a day contained — cite it inline and render nothing." + ], "latency": "fast network", "inputSchema": { "type": "object", @@ -4610,12 +4631,12 @@ "title": "Get Action Items", "summary": "Retrieve the user's tasks with optional completion and due-date filters.", "bullets": [ - "Use for completed tasks, date ranges, or the full task list.", - "For voice, prefer get_tasks for plain overdue/due-today questions." + "Use for completed tasks or an explicit date range.", + "For voice, prefer get_tasks for any plain question about the open list." ] }, "voice": { - "realtimeDescription": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish'), a DATE RANGE ('what's due next week'), or the FULL list ('all my tasks') — for plain 'what's due today / overdue', prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns." + "realtimeDescription": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish') or a DATE RANGE ('what's due next week') — for any plain question about the open list, prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns." } }, { @@ -5247,14 +5268,14 @@ ], "capabilityDoc": { "title": "Get Tasks", - "summary": "Read the user's overdue and due-today tasks locally.", + "summary": "Read the user's open tasks locally: overdue, due today, and undated.", "bullets": [ "Use for plain voice questions like what are my tasks, what's due today, or what's on my list.", - "Prefer get_action_items for completed tasks, date ranges, or the full list." + "Prefer get_action_items for completed tasks or an explicit date range." ] }, "voice": { - "realtimeDescription": "Read the user's tasks (overdue + due today) locally and get them back as text to speak. Fast synchronous read — use this for 'what are my tasks', 'what's due today', 'what's on my list'. Reading tasks is always a direct call, never background work." + "realtimeDescription": "Read the user's open tasks locally and get them back as text to speak: everything overdue, everything due today, and everything on the list with no due date. This is the same list the Tasks page shows, so an empty result means the user genuinely has no open tasks — never say they have none without calling this first. Fast synchronous read — use it for 'what are my tasks', 'what's due today', 'what's on my list', 'what should I work on'. Reading tasks is always a direct call, never background work." } }, { diff --git a/desktop/macos/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index efa343ef7d8..28a9a36c5b7 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -299,9 +299,15 @@ describe("omi tool manifest", () => { expect(toolNamesForAdapter("pi-mono", { surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, })).toEqual(expect.arrayContaining(["get_canonical_goals", "render_chat_blocks", "search_chat_history", "show_rewind_evidence"])); - expect(enabled.find((tool) => tool.name === "render_chat_blocks")?.description).toContain( - "call this in the same turn whenever you retrieve, create, or summarize tasks", - ); + // The tool is for entities the user asked for or acted on, not for every + // entity a turn happened to read. The old "render whenever you retrieve" + // wording stacked three conversation cards above a summary that had merely + // cited those conversations. + const renderDescription = enabled.find((tool) => tool.name === "render_chat_blocks")?.description ?? ""; + expect(renderDescription).toContain("when the entity IS the answer"); + expect(renderDescription).toContain("sources belong in citations"); + expect(renderDescription).toContain("Render at most three"); + expect(renderDescription).not.toContain("whenever you retrieve"); expect(enabled.find((tool) => tool.name === "render_chat_blocks")?.description).toContain( "never use a local SQLite/execute_sql numeric row ID", ); @@ -483,3 +489,24 @@ describe("omi tool manifest", () => { } }); }); + +describe("native component guidance", () => { + const byName = (name: string) => + [...omiToolManifest, ...chatFirstToolManifest].find((tool) => tool.name === name); + + it("teaches conversation and memory retrieval to render the component when the entity is the answer", () => { + expect(byName("get_conversations")?.promptGuidelines?.join("\n")).toContain("captureLink"); + expect(byName("get_conversations")?.promptGuidelines?.join("\n")).toContain("'pick one'"); + expect(byName("search_conversations")?.promptGuidelines?.join("\n")).toContain("captureLink"); + expect(byName("get_memories")?.promptGuidelines?.join("\n")).toContain("memoryLink"); + expect(byName("search_memories")?.promptGuidelines?.join("\n")).toContain("memoryLink"); + expect(byName("get_action_items")?.promptGuidelines?.join("\n")).toContain("a count, never their names"); + }); + + it("makes the component the default for anything Omi draws natively, with prose reserved for reading", () => { + const lead = byName("render_chat_blocks")?.promptGuidelines?.[0] ?? ""; + expect(lead).toContain("Default to a component"); + expect(lead).toContain("a summary, a recap, an analysis, a comparison, a count"); + expect(lead).toContain("a bold title with a citation number is not a substitute"); + }); +}); diff --git a/desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json b/desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json new file mode 100644 index 00000000000..70f022fe5f2 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json @@ -0,0 +1,3 @@ +{ + "change": "A long reply now stays whole once it finishes instead of collapsing to its first paragraph, and asking by voice what's on your list returns the same tasks the Tasks page shows — including ones overdue by more than a week and ones that have been sitting there without a date" +} diff --git a/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json b/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json new file mode 100644 index 00000000000..d2b9749c80d --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json @@ -0,0 +1,3 @@ +{ + "change": "Added \"Select Text\" to every chat message — it opens a selectable copy you can drag through and copy from — alongside a right-click Copy, a tighter transcript, and a mark on any reply that was cut off" +} diff --git a/desktop/macos/changelog/unreleased/20260902-one-chat-shell.json b/desktop/macos/changelog/unreleased/20260902-one-chat-shell.json new file mode 100644 index 00000000000..4ebc500135a --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260902-one-chat-shell.json @@ -0,0 +1,3 @@ +{ + "change": "Every account now gets the same chat window, and every card in a reply — tasks, goals, conversations, memories, captures, questions — is something you can act on wherever you read it" +} diff --git a/desktop/macos/e2e/CORE_E2E.md b/desktop/macos/e2e/CORE_E2E.md index 6eef2c7b757..d4583f4db14 100644 --- a/desktop/macos/e2e/CORE_E2E.md +++ b/desktop/macos/e2e/CORE_E2E.md @@ -69,7 +69,6 @@ Local full T0 (includes backend preflight + pytest desktop contracts): | Rewind artifact persistence / recovery / privacy admission | T2 | | ChatProvider / agent runtime | T0 + T3 | | Sidebar / navigation | T1 | -| Home stage (hub/chat/connect), chat-first shell | T2 (`home-stage.yaml`, cohort bundle) | | Spatial overlay | T1 (`spatial-overlay-harness.sh`) | | Memories / tasks CRUD surfaces | T2 | | Secondary surfaces (detail, vocabulary, goals, billing, privacy mutations) | T2 + Live P2 for manual-only | @@ -93,7 +92,6 @@ Local T2 and fault suites remain available as engineering QA tools. They do not | `tasks` | v2 | typed bridge | 2 | Navigate + snapshot | | `settings-basic` | v2 | typed bridge | 2 | Settings sections + Advanced snapshot | | `dashboard` | v2 | typed bridge | 2 | Dashboard load + conversation list snapshot | -| `home-stage` | v2 | typed bridge | 2 | Home hub/chat/connect via `homeMode` assertions — chat-first bundle only (that shell mounts the stage) | | `chat-fault-5xx` | v2 | typed bridge | fault | Backend 5xx via `omi-fault-inject` (`--fault-suite`) | | `language` | v2 | typed bridge | 2 | Transcription language set + snapshot | | `tasks-crud` | v2 | typed bridge | 2 | Task create/toggle/delete via bridge | diff --git a/desktop/macos/e2e/feature-vector.md b/desktop/macos/e2e/feature-vector.md index db1dee50573..2abab145e67 100644 --- a/desktop/macos/e2e/feature-vector.md +++ b/desktop/macos/e2e/feature-vector.md @@ -37,10 +37,10 @@ Prioritized feature map to guide desktop E2E coverage. Uses the same two-dimensi | # | Feature | Layer | Priority | Bridge | Walker | Coverage Status | |---|---------|-------|----------|--------|--------|-----------------| -| 1 | Dashboard — conversations list, refresh | intelligence (3) | 9 | 2 | 2 | ✅ flow: `dashboard.yaml` (nav + `conversation_list_snapshot`) | +| 1 | Dashboard — conversations list, refresh | intelligence (3) | 9 | 2 | 2 | ✅ flow: `home.yaml` (nav + `conversation_list_snapshot`) | | 2 | Chat — send message, AI response | intelligence (3) | 9 | 2 | 2 | ✅ flow: `chat-hermetic.yaml` | | 3 | Sidebar navigation — all sections | retrieval-action (3) | 9 | 2 | 3 | ✅ flow: `navigation.yaml` | -| 4 | Home stage (hub / chat / connect) | intelligence (3) | 9 | 2 | 2 | ✅ flow: `home-stage.yaml` (chat-first bundle) | +| 4 | Home stage (hub / chat / connect) | intelligence (3) | 9 | 2 | 2 | ⛔️ retired: `DashboardPage` was the only view that rendered the stage and it is deleted (#12598); Home is `QueryShellHome` and reports no stage | | 5 | Capture lifecycle (hermetic transcript seam) | capture (5) | 15 | 2 | 1 | ✅ flow: `capture-lifecycle.yaml` | | 6 | Screen capture (Rewind) | capture (5) | 15 | 0 | 2 | ⚠️ manual: `rewind.yaml`, `screen-recording-permission.yaml` (TCC) | | 7 | Audio recording (desktop mic) | capture (5) | 15 | 0 | 1 | ⚠️ manual: `audio-recording.yaml` (mic permission); added `recording-finalization.yaml` | diff --git a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml index c83ff5f7059..a31391984bc 100644 --- a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml +++ b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml @@ -1,7 +1,7 @@ version: 2 name: chat-first-capability-isolation tier: manual -description: "Manual local/offline capability-off assertion. Run it once for each isolated fixture case in the two-launch matrix below." +description: "Manual local/offline capability-off assertion: the one shell still mounts and its content blocks still render, non-actionable. Run it once for each isolated fixture case in the two-launch matrix below." app: non-prod # A harness invocation owns one automation port, hence one named app. It cannot # switch apps mid-run; prepare, launch, and run this *single-case* assertion @@ -31,11 +31,13 @@ app: non-prod covers: # Each case below drives this root's real sampled-control failure/disabled path. - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - - desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift - desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift - # S1 traverses the legacy half of the exact-route visibility wait; the - # enabled-control flow covers the Chat-first half without fabricating state. + # S1 traverses the exact-route visibility wait on a capability-off bundle: the + # shell mounts identically, so the wait must succeed on the same shellVariant. - desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift preconditions: - automation_bridge_ready @@ -45,15 +47,21 @@ preconditions: - its_fresh_named_non_production_bundle_launched_on_the_matching_port steps: + # Capability-off is no longer a different shell. It is the same shell with the + # kernel features dormant: cards still render, task check-off still works + # (it binds TasksStore, not the projection), links still navigate, and only the + # question card's options go dim. The old assertion here — `shellVariant: legacy` + # — described a shell that no longer exists. - id: S1 - name: Navigate the prepared capability-off bundle to its legacy chat surface (Home) + name: Navigate the prepared capability-off bundle to the one chat surface bridge.navigate: target: chat activateApp: false wait: - state.shellVariant: legacy + state.shellVariant: chat_first - id: S2 - name: Assert the sampled server capability remained off and the legacy Chat shell stayed usable + name: Assert the sampled server capability stayed off while the one shell stayed mounted and usable state.expect: - state.shellVariant: legacy + state.shellVariant: chat_first + state.visibleChatFirstRoute: chat diff --git a/desktop/macos/e2e/flows/chat-first-cohesive.yaml b/desktop/macos/e2e/flows/chat-first-cohesive.yaml index c5f438bdc7b..ed8d38dfcf2 100644 --- a/desktop/macos/e2e/flows/chat-first-cohesive.yaml +++ b/desktop/macos/e2e/flows/chat-first-cohesive.yaml @@ -28,6 +28,8 @@ covers: # save_knowledge_graph discovery_text resolves through backend KG extract SSOT. - desktop/macos/Desktop/Sources/Services/KnowledgeGraphToolSupport.swift - desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift + # The one renderer every Chat surface hands a grouped rich block to. + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift - desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift # S14-S22 prove a capture is staged as a typed, removable composer reference, # persists on the accepted user turn after the composer clears, and reopens @@ -38,6 +40,14 @@ covers: - desktop/macos/Desktop/Sources/Providers/ChatProvider+AutomationSnapshot.swift - desktop/macos/Desktop/Sources/Chat/AgentQueryResult.swift - desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift + # Every rendered assistant row mounts this file's popover anchor. The flow + # covers that mount and teardown; the selectable text view's own behaviour + # (selectable, not editable, one view across rebuilds) is asserted + # hermetically in ChatSelectableTextSurfaceTests, not here. + - desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift + # The prose parse and measured height this file caches are exercised by the + # same mounts the flow drives; the cache changes no visible behaviour. + - desktop/macos/Desktop/Sources/MainWindow/Components/ChatProseRenderCache.swift - desktop/macos/Desktop/Sources/MainWindow/Components/StableChatCardHeader.swift - desktop/macos/Desktop/Sources/MainWindow/Components/ChatConversationReferencePill.swift # S8 drives the Tasks-page closure, including its bounded attempt/terminal telemetry. diff --git a/desktop/macos/e2e/flows/chat-first.yaml b/desktop/macos/e2e/flows/chat-first.yaml index ef58e9445e1..2acc9e866c9 100644 --- a/desktop/macos/e2e/flows/chat-first.yaml +++ b/desktop/macos/e2e/flows/chat-first.yaml @@ -19,6 +19,22 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - desktop/macos/Desktop/Sources/MainWindow/MemoryHubPage.swift + # The first-48h activation surfaces (#12608). `home-stage.yaml` drove these + # through `DashboardPage`; that page is gone, and the Chat route is where they + # live now — the summary card is transcript chrome and the prefilled draft + # lands in the one composer. + - desktop/macos/Desktop/Sources/Automation/DesktopAutomationActivationActions.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCard.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCoordinator.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryPresentation.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+DailySummary.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummaryStatsRow.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummaryStore.swift + - desktop/macos/Desktop/Sources/Services/APIClient/APIClient+DailySummaries.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/DayZeroChips.swift + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeSuggestionsStore.swift + - desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift + - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift preconditions: - automation_bridge_ready - auth_ready @@ -129,6 +145,37 @@ steps: state.shellVariant: chat_first state.chatFirstRoute: chat + # The first-48h activation surfaces, on the route that now owns them. These + # were `home-stage.yaml` S9-S12; that flow asserted `state.homeMode` first, + # which only `DashboardPage` ever published, so it went with the page. + - id: S11a + name: A prefilled chat request lands in the composer unsent + # The first-real-app card and the daily-summary follow-up both enter through + # this one seam (`openMainAppChat(prefilledDraft:)`), which `QueryShellHome` + # consumes on the Chat route. Nothing may be sent. + bridge.action: + name: open_chat_prefilled + params: + query: "Summarize what's on my screen" + expect: + ok: true + + - id: S11b + name: The draft is the composer text + bridge.action: + name: chat_drafts_snapshot + expect: + result.detail.main: "Summarize what's on my screen" + + - id: S11c + name: Daily summary store answers shape-only + # Day 0 and quiet days legitimately have no summary; the action must answer either way. + bridge.action: + name: daily_summary_snapshot + expect: + ok: true + result.detail.hasSummary: { exists: true } + - id: S12 name: Assert the bridge did not report a route failure log.expect: diff --git a/desktop/macos/e2e/flows/chat-hermetic.yaml b/desktop/macos/e2e/flows/chat-hermetic.yaml index d7989d5b1fb..5add67fb4e6 100644 --- a/desktop/macos/e2e/flows/chat-hermetic.yaml +++ b/desktop/macos/e2e/flows/chat-hermetic.yaml @@ -48,6 +48,7 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift - desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift - desktop/macos/Desktop/Sources/Providers/ChatProvider.swift + - desktop/macos/Desktop/Sources/Chat/ChatStreamingBuffer.swift - desktop/macos/Desktop/Sources/Providers/ChatProvider+AutomationSnapshot.swift - desktop/macos/Desktop/Sources/DesktopAutomationBridge+ResponseContext.swift - desktop/macos/Desktop/Sources/Chat/AgentQueryResult.swift diff --git a/desktop/macos/e2e/flows/dashboard.snapshot.json b/desktop/macos/e2e/flows/dashboard.snapshot.json deleted file mode 100644 index 134f1391791..00000000000 --- a/desktop/macos/e2e/flows/dashboard.snapshot.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "version": 1, - "flow": "dashboard", - "flowHash": "16f607bc98c082c2", - "device": { - "model": "unknown", - "resolution": "unknown" - }, - "createdAt": "2026-03-20T06:57:42.156Z", - "runId": "_iDoTIw", - "totalDurationMs": 498000, - "verifySteps": [ - "S1", - "S2", - "S3", - "S4", - "S5", - "S6" - ], - "steps": { - "S1": { - "kind": "action", - "waitAfterMs": 200, - "durationMs": 68000 - }, - "S2": { - "kind": "action", - "waitAfterMs": 200, - "durationMs": 187000 - }, - "S3": { - "kind": "verify", - "waitAfterMs": 200, - "durationMs": 78000 - }, - "S4": { - "kind": "verify", - "waitAfterMs": 200, - "durationMs": 17000 - }, - "S5": { - "kind": "action", - "waitAfterMs": 200, - "durationMs": 46000 - }, - "S6": { - "kind": "verify", - "waitAfterMs": 500, - "durationMs": 102000 - } - } -} \ No newline at end of file diff --git a/desktop/macos/e2e/flows/dashboard.yaml b/desktop/macos/e2e/flows/dashboard.yaml deleted file mode 100644 index d9dfcb7bae4..00000000000 --- a/desktop/macos/e2e/flows/dashboard.yaml +++ /dev/null @@ -1,58 +0,0 @@ -version: 2 -name: dashboard -tier: 2 -description: Dashboard load and refresh via automation bridge -app: non-prod -covers: - - desktop/macos/Desktop/Sources/Theme/InkGlassHitRegions.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift - - desktop/macos/Desktop/Sources/AppState/AppState+DataLoading.swift - - desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift - - desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/ConversationListView.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/UserFacingErrorPresentation.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift - - desktop/macos/Desktop/Sources/WhatsNewToast.swift - - desktop/macos/Desktop/Sources/Theme/Ink.swift - - desktop/macos/Desktop/Sources/Theme/InkGlass.swift - - desktop/macos/Desktop/Sources/Theme/InkType.swift - - desktop/macos/Desktop/Sources/Theme/WindowGlass.swift - - desktop/macos/Desktop/Sources/Theme/OmiChrome.swift - - desktop/macos/Desktop/Sources/Theme/OmiColors.swift - - desktop/macos/Desktop/Sources/Theme/OmiToggleStyle.swift - - desktop/macos/Desktop/Sources/MainWindow/GlassShellChrome.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/GlassContentChrome.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/PersonaPage.swift -preconditions: - - automation_bridge_ready - -steps: - - id: S1 - name: Navigate to Dashboard - bridge.navigate: - target: dashboard - activateApp: false - wait: - state.selectedTabIndex: 0 - - - id: S2 - name: Refresh dashboard data - bridge.action: - name: refresh_all_data - - - id: S3 - name: Conversation list snapshot - bridge.action: - name: conversation_list_snapshot - expect: - ok: true - - - id: S4 - name: Logs clean - log.expect: - absent: - - "DesktopAutomationBridge: failed" diff --git a/desktop/macos/e2e/flows/floating-bar-functional.yaml b/desktop/macos/e2e/flows/floating-bar-functional.yaml index e8b2d1afd41..c0003bc70c5 100644 --- a/desktop/macos/e2e/flows/floating-bar-functional.yaml +++ b/desktop/macos/e2e/flows/floating-bar-functional.yaml @@ -14,6 +14,8 @@ covers: - desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationCardLead.swift - desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift - desktop/macos/Desktop/Sources/MainWindow/ClickThroughView.swift + # Where the notch's own agent-exit routing decision lives. + - desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift - desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift - desktop/macos/Desktop/Sources/FloatingControlBar/CursorScreenTracker.swift - desktop/macos/Desktop/Sources/FloatingControlBar/DelayedActionScheduler.swift diff --git a/desktop/macos/e2e/flows/goals-dashboard.yaml b/desktop/macos/e2e/flows/goals-dashboard.yaml index 072583af3b3..955232b6083 100644 --- a/desktop/macos/e2e/flows/goals-dashboard.yaml +++ b/desktop/macos/e2e/flows/goals-dashboard.yaml @@ -5,7 +5,6 @@ description: Hermetic dashboard goals create + snapshot via bridge actions app: non-prod covers: - desktop/macos/Desktop/Sources/Providers/ChatToolExecutor+CanonicalGoals.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift - desktop/macos/Desktop/Sources/MainWindow/Pages/GoalsHistoryPage.swift - desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/Goals/GoalGenerationService.swift - desktop/macos/Desktop/Sources/ProactiveAssistants/Core/AssistantProtocol.swift diff --git a/desktop/macos/e2e/flows/home-spine.yaml b/desktop/macos/e2e/flows/home-spine.yaml index de97b89b378..9dcadf0426d 100644 --- a/desktop/macos/e2e/flows/home-spine.yaml +++ b/desktop/macos/e2e/flows/home-spine.yaml @@ -20,6 +20,8 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/ServerPaging.swift - desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryVisibilityGuardrails.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift + # Home's corner counts and goal rows read this view model. + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift preconditions: - auth_ready diff --git a/desktop/macos/e2e/flows/home-stage.yaml b/desktop/macos/e2e/flows/home-stage.yaml deleted file mode 100644 index eb2ef9dbbe9..00000000000 --- a/desktop/macos/e2e/flows/home-stage.yaml +++ /dev/null @@ -1,160 +0,0 @@ -version: 2 -name: home-stage -tier: 2 -description: >- - The Home stage (hub / chat / connect) driven through the automation bridge. The stage is rendered - by DashboardPage, which the chat-first shell mounts on its Chat route — hence the cohort - precondition. The legacy shell's Home is the query surface and has no stage at all, so this flow - asserts the shell before it asserts a mode. -app: non-prod -covers: - - desktop/macos/Desktop/Sources/Automation/DesktopAutomationActivationActions.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/DayZeroChips.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCard.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryCoordinator.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/ChatDailySummaryPresentation.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+DailySummary.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummarySection.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeDailySummaryStore.swift - - desktop/macos/Desktop/Sources/Services/APIClient/APIClient+DailySummaries.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/HomeAskBarControls.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/HomePresentationTokens.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStagePresentation.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeSuggestionsStore.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift - # Knows-list impression ledger and the daily-summary memory review rows render on the same stage. - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsImpressionLedger.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/AnalyticsManager+HomeKnows.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/MemoryReviewCard.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/MemoryReviewRowView.swift - - desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift - - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift - # The five home_* actions every step below drives, lifted out of the registry. - - desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift - # S1 is the guard: these decide whether a stage exists at all, and therefore whether - # `state.homeMode` is a reading or a fabrication. - - desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift - - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift -preconditions: - - automation_bridge_ready - - auth_ready - # DashboardPage — and so the stage — is mounted only by the chat-first shell. Prepare the bundle - # the same way `chat-first.yaml` does; on a legacy bundle S1 fails immediately and says why. - - chat_first_cohort_named_bundle - -steps: - - id: S0 - name: Reset main chat for flow isolation - bridge.action: - name: reset_main_chat - expect: - result.detail.reset: "true" - - - id: S1 - name: Mount the shell that owns the stage - # Deliberately `chat`, not `dashboard`. `dashboard` resolves to the More/Dashboard route, where - # DashboardPage is handed an `onOpenPrimaryChat` and `home_open_chat` navigates away to the Chat - # route instead of opening the inline chat — S3 would then assert against a page it had left. - bridge.navigate: - target: chat - activateApp: false - wait: - state.shellVariant: chat_first - state.chatFirstRoute: chat - - - id: S2 - name: Collapse to the empty-history resting hub - bridge.action: - name: home_close_panel - wait: - state.homeMode: hub - - - id: S3 - name: Open inline chat - bridge.action: - name: home_open_chat - wait: - state.homeMode: chat - - - id: S4 - name: Home ask stub (no LLM wait) - bridge.action: - name: home_ask - params: - query: "[[MARKER:home-stage-stub]]" - expect: - result.detail.sent: "[[MARKER:home-stage-stub]]" - wait: - state.homeMode: chat - - - id: S5 - name: Toggle connect tray - bridge.action: - name: home_connect_toggle - wait: - state.homeMode: connect - - - id: S6 - name: Collapse back to loaded-history chat - bridge.action: - name: home_close_panel - wait: - state.homeMode: chat - - - id: S7 - name: Leaving the stage stops reporting a mode - # The field describes a mounted surface. Navigating away must clear it rather than leave the last - # mode standing — a stale `connect` here is the same class of lie as the `hub` the legacy shell - # used to report for a Home that had no stage. - bridge.navigate: - target: tasks - activateApp: false - wait: - state.chatFirstRoute: tasks - state.homeMode: { exists: false } - - - id: S8 - name: Connect toggle refuses once no stage is mounted - # S7 just proved there is no stage here, so this is the refusal path on a live bridge rather - # than a unit-test fixture: the action must report an error instead of answering "ok" and doing - # nothing. The wording it uses is asserted in HomeStageCloseSemanticsTests, not pinned here. - bridge.action: - name: home_connect_toggle - expect: - result.detail.error: { exists: true } - - - id: S9 - name: A prefilled chat request lands in the composer unsent - # The first-real-app card and the daily-summary follow-up both enter through this one seam - # (`openMainAppChat(prefilledDraft:)`). The draft must be in the main composer and nothing sent. - bridge.action: - name: open_chat_prefilled - params: - query: "Summarize what's on my screen" - expect: - ok: true - - - id: S10 - name: The draft is the composer text - bridge.action: - name: chat_drafts_snapshot - expect: - result.detail.main: "Summarize what's on my screen" - - - id: S11 - name: Daily summary store answers shape-only - # Day 0 and quiet days legitimately have no summary; the action must answer either way. - bridge.action: - name: daily_summary_snapshot - expect: - ok: true - result.detail.hasSummary: { exists: true } - - - id: S12 - name: Logs clean - log.expect: - absent: - - "DesktopAutomationBridge: failed" diff --git a/desktop/macos/e2e/flows/home.yaml b/desktop/macos/e2e/flows/home.yaml index a3b2784e194..a135abcb02d 100644 --- a/desktop/macos/e2e/flows/home.yaml +++ b/desktop/macos/e2e/flows/home.yaml @@ -1,7 +1,7 @@ version: 2 name: home tier: manual -description: Home tab — chat-first panel under the always-on search bar, plus its header controls and Capture/Listening status (v0.12.119+ redesign, replaces dashboard.yaml) +description: Home tab — chat-first panel under the always-on search bar, plus its header controls and Capture/Listening status (v0.12.119+ redesign; the only Home flow since dashboard.yaml was retired with DashboardPage) app: com.omi.computer-macos covers: - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -10,6 +10,9 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/DesktopWindowLayoutPolicy.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellModel.swift + # The four home_* bridge actions this surface observes. (The fifth, + # home_connect_toggle, refuses here: nothing renders a Connect tray any more.) + - desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryHeroBar.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QuerySearchBar.swift - desktop/macos/Desktop/Sources/Analytics/SearchAnalytics.swift diff --git a/desktop/macos/e2e/flows/navigation.yaml b/desktop/macos/e2e/flows/navigation.yaml index c15c5d68f8f..06ccb0a9b70 100644 --- a/desktop/macos/e2e/flows/navigation.yaml +++ b/desktop/macos/e2e/flows/navigation.yaml @@ -7,8 +7,6 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/ShellClickThrough.swift - desktop/macos/Desktop/Sources/OmiApp.swift - desktop/macos/Desktop/Sources/Sound/OmiUISound.swift - - desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift - - desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift - desktop/macos/Desktop/Sources/MainWindow/SidebarNavItem.swift - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - desktop/macos/Desktop/Sources/MainWindow/PageGlassLane.swift @@ -135,19 +133,17 @@ steps: - Home - id: S9 - name: Verify the legacy Home and Settings sidebars + name: Verify the Settings section list keeps its own ground do: >- - In Settings > Advanced, enable Use old Home design and return to Home. Verify the left - sidebar is a visibly frosted lane and shows Conversations as a separate destination from - Memories, then click Conversations. Open Settings from the gear, verify its left section list - keeps the same grounded lane instead of showing the desktop directly underneath it, and click - General followed by Account & Plan. Each click must change the destination inside Omi rather - than reaching the app behind its transparent top-level window. + Open Settings from the gear and verify its left section list is a grounded lane rather than + showing the desktop directly underneath it, then click General followed by Account & Plan. + Each click must change the destination inside Omi rather than reaching the app behind its + transparent top-level window. (The "Use old Home design" preference and the frosted legacy + navigation sidebar this step used to enable were deleted with the second shell, #12598.) expect: text_visible: - Settings - Account & Plan - - Conversations - id: S10 name: Verify the window has no chrome and is still closable, minimisable and movable diff --git a/desktop/macos/e2e/flows/tasks.yaml b/desktop/macos/e2e/flows/tasks.yaml index 047a7464724..ba96379b5e2 100644 --- a/desktop/macos/e2e/flows/tasks.yaml +++ b/desktop/macos/e2e/flows/tasks.yaml @@ -5,6 +5,8 @@ description: Tasks tab — search, Today/No Deadline sections, keyboard toolbar app: com.omi.computer-macos covers: - desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift + # The exact-record handoff TasksPage consumes on arrival. + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift - desktop/macos/Desktop/Sources/MainWindow/Pages/TasksViewModel+SearchAnalytics.swift - desktop/macos/Desktop/Sources/Rewind/Core/ActionItemStorage.swift - desktop/macos/Desktop/Sources/MainWindow/Tasks/SuggestedTasksSection.swift @@ -19,6 +21,9 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/Tasks/TaskDetailSourceNavigator.swift - desktop/macos/Desktop/Sources/MainWindow/Tasks/RewindEvidenceCard.swift - desktop/macos/Desktop/Sources/Stores/TasksStore.swift + # S6 reads the lanes these two build — the ones the assistant answers from — + # and compares them against the page's own Today count. + - desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift - desktop/macos/Desktop/Sources/Stores/TasksStore+BulkSelection.swift - desktop/macos/Desktop/Sources/Rewind/Core/ActionItemStorage+BulkDelete.swift preconditions: @@ -106,3 +111,19 @@ steps: do: "Verify the search bar is present at the top. Check for filter/settings icons next to the search bar and the add (+) button." expect: interactive_count: { min: 2 } + + - id: S6 + name: The assistant's lanes carry what this page shows + do: >- + Count the tasks the page lists under 'Today' (it holds overdue and + due-today rows together), then run `./scripts/omi-ctl action + tasks_snapshot`. Verify `overdue_count` + `today_count` equals the page's + Today count, and that `task_count` is non-zero whenever the page lists any + task. These are the lanes the voice `get_tasks` tool, the About-user card + and SuggestionAssistant read; a backlog older than a week, or one captured + from a conversation, used to be missing from them alone, so the assistant + said "you don't have any tasks overdue or due today" to someone looking at + thirty of them. + expect: + text_visible: + - Today diff --git a/desktop/macos/scripts/check-single-chat-shell.py b/desktop/macos/scripts/check-single-chat-shell.py new file mode 100755 index 00000000000..8fa0b8d5eb6 --- /dev/null +++ b/desktop/macos/scripts/check-single-chat-shell.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Keep the desktop main window on exactly one chat shell, with no inert blocks. + +The app used to mount one of two shells behind a server sample and a local +preference, and six of the journal's content-block kinds rendered as controls on +one of them and as nothing at all on the other. Both halves of that are gone +(#12598), and both are the kind of thing that grows back one symbol at a time — +a "just for now" preference, an `Optional` context, one `== nil` fork — without +any single diff looking like a second shell. + +This is a **static tripwire**, not behavioral coverage. `OneChatShellRichBlockTests` +proves what the blocks actually do; this only proves the vocabulary that made a +second shell expressible has not come back. + +Every banned symbol below is checked against production sources only +(`Desktop/Sources`). Tests may name a symbol in a string to assert its absence. +Comments and string literals are blanked before matching, so prose that names a +deleted type — including this file's own remedies quoted in a Swift comment — is +not a violation. + +Exit codes: 0 clean, 1 violations found, 2 usage/IO error. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +DESKTOP_DIR = SCRIPT_DIR.parent +DEFAULT_SOURCES_DIR = DESKTOP_DIR / "Desktop" / "Sources" + +# (pattern, why it is banned and what to do instead, scan strings too) +# +# `scan_strings` is on for the two `@AppStorage` keys, whose only spelling in +# production *is* a string literal — masking strings would make them unfindable. +# Everything else is a Swift identifier, so a string that mentions it is prose or +# a test fixture, not a use. +BANNED: list[tuple[str, str, bool]] = [ + ( + r"\bChatFirstShellCapabilitySample\b", + "the capability sample no longer selects a shell; use ChatFirstCapabilitySample, " + "which only gates kernel features", + False, + ), + ( + r"\bChatFirstShellVariant\b", + "there is one shell, so there is no variant to resolve; the automation snapshot pins " + "DesktopAutomationSnapshot.singleShellVariant", + False, + ), + ( + r"\buseLegacyHomeDesign\b", + "the legacy Home shell and its preference are deleted; every account gets ChatFirstShell", + True, + ), + ( + r"\buseOldestHomeDesign\b", + "the widgets-and-chat Home is deleted; every account gets ChatFirstShell", + True, + ), + ( + r"\busesLegacyPresentation\b", + "QueryShellHome has one presentation; the DashboardPage fork is deleted", + False, + ), + ( + r"\brichBlockRenderingEnabled\b", + "every Chat surface renders every content block; the flag that made six of them " + "EmptyView on some surfaces is deleted", + False, + ), + ( + r"chatFirstRichBlockContext\s*==\s*nil", + "the content-block context is non-optional on every host; a nil fork means a surface " + "that silently drops cards", + False, + ), + ( + r"chatFirstRichBlockContext\s*!=\s*nil", + "the content-block context is non-optional on every host; a nil fork means a surface " + "that silently drops cards", + False, + ), + ( + r"chatFirstRichBlockContext:\s*ChatFirstRichBlockContext\?", + "declare it as a non-optional `ChatFirstRichBlockContext`; auxiliary surfaces build one " + "with `ChatFirstRichBlockContext.auxiliary(chatProvider:)`", + False, + ), + ( + r"\bDashboardPage\s*\(", + "DashboardPage and its inline chat are deleted; the one chat destination is QueryShellHome", + False, + ), +] + +COMPILED = [(re.compile(pattern), remedy, scan_strings) for pattern, remedy, scan_strings in BANNED] + + +def mask_comments_and_strings(text: str, *, keep_strings: bool = False) -> str: + """Blank Swift comments and string literals, preserving offsets and newlines. + + A banned name written in prose or in a test fixture string is not a + reintroduction of the thing. Blanking rather than deleting keeps every line + number identical to the original file. + """ + out = list(text) + length = len(text) + index = 0 + + def blank(start: int, end: int) -> None: + for position in range(start, min(end, length)): + if out[position] != "\n": + out[position] = " " + + while index < length: + char = text[index] + + if char == "/" and text.startswith("//", index): + end = text.find("\n", index) + end = length if end == -1 else end + blank(index, end) + index = end + continue + + if char == "/" and text.startswith("/*", index): + depth = 1 + cursor = index + 2 + while cursor < length and depth: + if text.startswith("/*", cursor): + depth += 1 + cursor += 2 + elif text.startswith("*/", cursor): + depth -= 1 + cursor += 2 + else: + cursor += 1 + blank(index, cursor) + index = cursor + continue + + if char in '#"' and not keep_strings: + hashes = 0 + cursor = index + while cursor < length and text[cursor] == "#": + hashes += 1 + cursor += 1 + if cursor >= length or text[cursor] != '"': + index = cursor + 1 if hashes else index + 1 + continue + + pound = "#" * hashes + multiline = text.startswith('"""', cursor) + terminator = ('"""' + pound) if multiline else ('"' + pound) + cursor += 3 if multiline else 1 + + while cursor < length: + if hashes == 0 and text[cursor] == "\\": + cursor += 2 + continue + if hashes and text.startswith("\\" + pound, cursor): + cursor += 1 + hashes + 1 + continue + if text.startswith(terminator, cursor): + cursor += len(terminator) + break + if not multiline and text[cursor] == "\n": + break + cursor += 1 + + blank(index, cursor) + index = cursor + continue + + index += 1 + + return "".join(out) + + +def check_source(source: str, *, path_label: str) -> list[str]: + masked = mask_comments_and_strings(source) + comments_only = mask_comments_and_strings(source, keep_strings=True) + lines = source.splitlines() + errors: list[str] = [] + for pattern, remedy, scan_strings in COMPILED: + haystack = comments_only if scan_strings else masked + for match in pattern.finditer(haystack): + lineno = haystack.count("\n", 0, match.start()) + 1 + text = lines[lineno - 1].strip() if lineno <= len(lines) else "" + errors.append(f"{path_label}:{lineno}: {text}\n {remedy}") + return errors + + +def find_violations(sources_dir: Path) -> list[str]: + errors: list[str] = [] + for path in sorted(sources_dir.rglob("*.swift")): + try: + source = path.read_text(encoding="utf-8") + except OSError as exc: # pragma: no cover - unreadable file is a real failure + print(f"check-single-chat-shell: cannot read {path}: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + try: + label = str(path.relative_to(Path.cwd())) + except ValueError: + label = str(path) + errors.extend(check_source(source, path_label=label)) + return errors + + +def run_self_test() -> None: + """Prove both directions on in-memory fixtures, without touching the tree.""" + clean = ( + "import SwiftUI\n\n" + "struct ChatBubble: View {\n" + " let chatFirstRichBlockContext: ChatFirstRichBlockContext\n" + "}\n" + '// A comment may name useLegacyHomeDesign and DashboardPage( without failing.\n' + 'let hint = "richBlockRenderingEnabled"\n' + ) + clean_errors = check_source(clean, path_label="fixture-clean.swift") + if clean_errors: + raise SystemExit(f"self-test false positive on clean fixture: {clean_errors}") + + for fixture, needle in ( + ('@AppStorage("useLegacyHomeDesign") private var flag = false\n', "useLegacyHomeDesign"), + ("var context: ChatFirstRichBlockContext? = nil\nlet x = chatFirstRichBlockContext == nil\n", + "chatFirstRichBlockContext"), + ("let g = group(blocks, richBlockRenderingEnabled: true)\n", "richBlockRenderingEnabled"), + ("var sample = ChatFirstShellCapabilitySample()\n", "ChatFirstShellCapabilitySample"), + ("body = DashboardPage(viewModel: viewModel)\n", "DashboardPage"), + ): + errors = check_source(fixture, path_label="fixture-fail.swift") + if not any(needle in error for error in errors): + raise SystemExit(f"self-test missed {needle!r} fail mode; errors={errors!r}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--sources-dir", + type=Path, + default=DEFAULT_SOURCES_DIR, + help="root of the Swift sources to scan (default: desktop/macos/Desktop/Sources)", + ) + parser.add_argument( + "--self-test", + action="store_true", + help="Run the in-memory fixtures for this checker, then exit.", + ) + args = parser.parse_args(argv) + + if args.self_test: + run_self_test() + print("OK: single-chat-shell checker self-test passed.") + return 0 + + sources_dir: Path = args.sources_dir + if not sources_dir.is_dir(): + print(f"check-single-chat-shell: sources dir not found: {sources_dir}", file=sys.stderr) + return 2 + + errors = find_violations(sources_dir) + if not errors: + print("ok: one chat shell, and every content block renders on every surface") + return 0 + + print("FAIL: a second chat shell (or an inert content block) is growing back") + for error in errors: + print(f"- {error}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/desktop/macos/scripts/check_desktop_test_quality.py b/desktop/macos/scripts/check_desktop_test_quality.py index 821f6644ab9..ef3d82bb55e 100755 --- a/desktop/macos/scripts/check_desktop_test_quality.py +++ b/desktop/macos/scripts/check_desktop_test_quality.py @@ -44,8 +44,8 @@ # Pinned debt ceilings. These may only decrease. Escaped sites are not counted. # Run with --print after improving tests, then lower both relevant values. -SOURCE_INSPECTION_FILE_BASELINE = 54 -SOURCE_INSPECTION_SITE_BASELINE = 147 +SOURCE_INSPECTION_FILE_BASELINE = 53 +SOURCE_INSPECTION_SITE_BASELINE = 144 WALL_CLOCK_WAIT_BASELINE = 16 MIN_REASON_LENGTH = 12 diff --git a/desktop/macos/scripts/omi-ctl b/desktop/macos/scripts/omi-ctl index ec7600251db..85c4601130a 100755 --- a/desktop/macos/scripts/omi-ctl +++ b/desktop/macos/scripts/omi-ctl @@ -144,11 +144,12 @@ raise SystemExit(0 if ready else 1) done echo "omi-ctl: timed out waiting for a live signed-in owner-ready main state" >&2; exit 1 ;; screens) - # Every token here must resolve in DesktopHomeView.resolvedAutomationTarget or, for the - # chat-first-only ones, ChatFirstRoute.primaryAutomationDestination. `focus` and `insight` were - # listed after their pages were deleted, so `omi-ctl navigate insight` posted a target the app - # answered "ok" to and then did nothing with — the failure mode the bridge exists to avoid. - echo "dashboard|home conversations chat memories tasks goals(chat-first) rewind apps|integrations settings permissions" ;; + # Every token here must resolve in ChatFirstRoute.automationVisibilityDestination. `focus` and + # `insight` were listed after their pages were deleted, so `omi-ctl navigate insight` posted a + # target the app answered "ok" to and then did nothing with — the failure mode the bridge exists + # to avoid. `goals` lost its "(chat-first)" note when the second shell did: there is one shell, + # and `help` opens Settings > About, where getting help from a person lives. + echo "dashboard|home conversations chat memories tasks goals rewind apps|integrations settings permissions help" ;; *) cat <<EOF omi-ctl — drive the Omi desktop app via the local automation bridge diff --git a/desktop/macos/scripts/omi-settings-seed.sh b/desktop/macos/scripts/omi-settings-seed.sh index 5a69c892484..b6c0e63959c 100755 --- a/desktop/macos/scripts/omi-settings-seed.sh +++ b/desktop/macos/scripts/omi-settings-seed.sh @@ -46,7 +46,6 @@ KEYS = [ "fontScale", "multiChatEnabled", "conversationsCompactView", - "useLegacyHomeDesign", "chatBridgeMode", "realtimeOmniProvider", "askModeEnabled", diff --git a/desktop/macos/tests/test-desktop-core-harness-readiness-teardown.sh b/desktop/macos/tests/test-desktop-core-harness-readiness-teardown.sh index 4332c55c437..4aa5199bc46 100755 --- a/desktop/macos/tests/test-desktop-core-harness-readiness-teardown.sh +++ b/desktop/macos/tests/test-desktop-core-harness-readiness-teardown.sh @@ -126,6 +126,22 @@ SH chmod +x "$fixture/backend/test-preflight.sh" } +# A port nothing is listening on. Readiness verifies a launched bundle's /health +# whenever --port is bound, and these cases are offline-only: on a Mac with any +# dev bundle up on the default 47777 the harness reached that check and sourced +# app-config.sh, which the fixture never provides. Probe with /dev/tcp so the +# stubbed python3 on PATH cannot answer for the kernel. +closed_port() { + local port + for port in $(seq 47901 47999); do + if ! (echo >/dev/tcp/127.0.0.1/"$port") >/dev/null 2>&1; then + printf '%s\n' "$port" + return 0 + fi + done + return 1 +} + run_readiness_case() { local scenario="$1" local keep_stack="${2:-0}" @@ -135,7 +151,9 @@ run_readiness_case() { local dev_up_marker="$fixture/dev-up.marker" local output="$fixture/output.txt" local status=0 - local args=(--readiness) + local port + port="$(closed_port)" || fail "no closed port available for the offline readiness fixture" + local args=(--readiness --port "$port") rm -rf "$fixture" prepare_fixture_repo "$fixture"