diff --git a/electron/preload.js b/electron/preload.js index 6201a4113..a8e9350aa 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -105,6 +105,11 @@ contextBridge.exposeInMainWorld('yanceDesktop', Object.freeze({ setActiveConversation: conversationId => ipcRenderer.invoke('desktop:set-active-conversation', { activeConversationId: String(conversationId || '') }), storeSnapshot: input => invokeStore('store:get-snapshot', input), storeSocialContext: input => invokeStore('store:get-social-context', input), + storeSearchWorkspace: input => invokeStore('store:search-workspace', input), + storeCreateTranslationJob: input => invokeStore('store:create-translation-job', input), + storeGetTranslationJob: input => invokeStore('store:get-translation-job', input), + storeCancelTranslationJob: input => invokeStore('store:cancel-translation-job', input), + storeRetryTranslationJob: input => invokeStore('store:retry-translation-job', input), storeGenerateReply: (input, options = {}) => invokeStoreCancelable('store:generate-reply', input, options), storeApproveReply: input => invokeStore('store:approve-reply', input), storeRejectReply: input => invokeStore('store:reject-reply', input), diff --git a/electron/r32StoreBridge.js b/electron/r32StoreBridge.js index a7a395399..a53ac466e 100644 --- a/electron/r32StoreBridge.js +++ b/electron/r32StoreBridge.js @@ -3,6 +3,11 @@ const CHANNELS = Object.freeze({ snapshot: 'store:get-snapshot', socialContext: 'store:get-social-context', + searchWorkspace: 'store:search-workspace', + createTranslationJob: 'store:create-translation-job', + getTranslationJob: 'store:get-translation-job', + cancelTranslationJob: 'store:cancel-translation-job', + retryTranslationJob: 'store:retry-translation-job', generateReply: 'store:generate-reply', cancelRequest: 'store:cancel-request', approveReply: 'store:approve-reply', @@ -26,6 +31,15 @@ function jsonBody(value) { return JSON.stringify(value || {}); } +function requiredIdentifier(value, name) { + const id = clean(value); + if (id) return id; + const error = new Error(`${name} is required`); + error.code = `${String(name || 'identifier').replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}_REQUIRED`; + error.reasonCode = error.code; + throw error; +} + function serializeBridgeError(error) { return { __yanceBridgeError: true, @@ -68,6 +82,38 @@ function installR32StoreBridge({ ipcMain, apiRequest }) { }); return apiRequest(`/api/r32/store/customers/${encodeURIComponent(contactId)}/social-context?${query}`); }, + [CHANNELS.searchWorkspace]: (_event, input = {}) => { + const queryText = clean(input.query); + const numericLimit = input.limit == null ? 80 : Number(input.limit); + const limit = Math.max(1, Math.min(200, Number.isFinite(numericLimit) ? numericLimit : 80)); + const query = new URLSearchParams({ q: queryText, limit: String(limit) }); + return apiRequest(`/api/r32/store/search?${query}`); + }, + [CHANNELS.createTranslationJob]: (_event, input = {}) => { + const messageId = requiredIdentifier(input.messageId, 'messageId'); + return apiRequest(`/api/r32/store/translations/messages/${encodeURIComponent(messageId)}/jobs`, { + method: 'POST', + body: jsonBody({ + force: input.force === true, + forceNew: input.forceNew === true, + timeoutMs: input.timeoutMs + }) + }); + }, + [CHANNELS.getTranslationJob]: (_event, input = {}) => { + const jobId = requiredIdentifier(input.jobId, 'jobId'); + return apiRequest(`/api/r32/store/translations/jobs/${encodeURIComponent(jobId)}`); + }, + [CHANNELS.cancelTranslationJob]: (_event, input = {}) => { + const jobId = requiredIdentifier(input.jobId, 'jobId'); + return apiRequest(`/api/r32/store/translations/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }); + }, + [CHANNELS.retryTranslationJob]: (_event, input = {}) => { + const jobId = requiredIdentifier(input.jobId, 'jobId'); + return apiRequest(`/api/r32/store/translations/jobs/${encodeURIComponent(jobId)}/retry`, { + method: 'POST', body: jsonBody({ timeoutMs: input.timeoutMs }) + }); + }, [CHANNELS.generateReply]: async (event, input = {}) => { const requestId = clean(input.__yanceBridgeRequestId); const body = { ...input }; diff --git a/integration/element-module/src/YanceWorkspace.tsx b/integration/element-module/src/YanceWorkspace.tsx index b987a126a..6fb96a701 100644 --- a/integration/element-module/src/YanceWorkspace.tsx +++ b/integration/element-module/src/YanceWorkspace.tsx @@ -1,6 +1,11 @@ import React from "react"; import { ProductExperienceShell } from "./product-experience/ProductExperienceShell"; +import type { RelationshipProjection } from "./product-experience/experienceTypes"; -export function YanceWorkspace(): React.JSX.Element { - return ; +type YanceWorkspaceProps = { + navigateSearchResult?: (relationship: RelationshipProjection) => Promise; +}; + +export function YanceWorkspace({ navigateSearchResult }: YanceWorkspaceProps): React.JSX.Element { + return ; } diff --git a/integration/element-module/src/index.tsx b/integration/element-module/src/index.tsx index 14ec2e498..cb47d4c5b 100644 --- a/integration/element-module/src/index.tsx +++ b/integration/element-module/src/index.tsx @@ -4,6 +4,15 @@ import React from "react"; import type { Api, Module, ModuleFactory } from "@element-hq/element-web-module-api"; import { YanceWorkspace } from "./YanceWorkspace"; import { ProductComposerAccessory } from "./product-experience/ProductComposerAccessory"; +import type { RelationshipProjection } from "./product-experience/experienceTypes"; + +function validMatrixRoomId(value: string): boolean { + return /^[!#][^:\s]+:[^\s]+$/u.test(value); +} + +function validMatrixPermalink(value: string): boolean { + return /^https:\/\/matrix\.to\/#\/[!#][^?\s]+/u.test(value); +} class YanceElementModule implements Module { public static readonly moduleApiVersion = "^1.0.0"; @@ -11,7 +20,25 @@ class YanceElementModule implements Module { public constructor(private readonly api: Api) {} public async load(): Promise { - this.api.customComponents.registerGlobalRightPanel(() => ); + const navigateSearchResult = async (relationship: RelationshipProjection): Promise => { + const permalink = relationship.matrixPermalink?.trim() || ""; + if (permalink && validMatrixPermalink(permalink)) { + this.api.navigation.toMatrixToLink(permalink); + return true; + } + + const roomId = relationship.matrixRoomId?.trim() || ""; + if (roomId && validMatrixRoomId(roomId)) { + this.api.navigation.openRoom(roomId); + return true; + } + + return false; + }; + + this.api.customComponents.registerGlobalRightPanel( + () => , + ); this.api.customComponents.registerComposerPreview( (_composerText, roomId) => Boolean(roomId), (props, originalComponent) => ( diff --git a/integration/element-module/src/product-experience/BilingualSearchPanel.tsx b/integration/element-module/src/product-experience/BilingualSearchPanel.tsx new file mode 100644 index 000000000..0eca7d20a --- /dev/null +++ b/integration/element-module/src/product-experience/BilingualSearchPanel.tsx @@ -0,0 +1,446 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + cancelTranslationJob, + createTranslationJob, + readTranslationJob, + retryTranslationJob, + searchWorkspace, +} from "./experienceProjection"; +import type { + BilingualSearchResult, + RelationshipProjection, + TranslationJobProjection, + WorkspaceSearchProjection, +} from "./experienceTypes"; + +const SEARCH_DEBOUNCE_MS = 260; +const JOB_POLL_MS = 700; +const MAX_POLL_FAILURES = 5; +const MAX_POLL_BACKOFF_MS = 10_000; +const ACTIVE_JOB_STATES = new Set(["queued", "running"]); +const RETRYABLE_JOB_STATES = new Set(["failed", "cancelled"]); + +type BilingualSearchPanelProps = { + relationships: readonly RelationshipProjection[]; + reducedMotion: boolean; + onSelectRelationship: (relationshipId: string) => void; + onNavigateRelationship?: (relationship: RelationshipProjection) => Promise; +}; + +function errorText(error: unknown, fallback: string): string { + if (error && typeof error === "object") { + const candidate = error as { message?: unknown; reasonCode?: unknown; code?: unknown }; + const code = String(candidate.reasonCode || candidate.code || "").trim(); + const message = String(candidate.message || "").trim(); + if (code && message && message !== code) return `${message} (${code})`; + if (code) return code; + if (message) return message; + } + return fallback; +} + +function normalizedStatus(job: TranslationJobProjection | null): string { + return String(job?.status || "").trim().toLowerCase(); +} + +function formatTime(value: string): string { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return ""; + try { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(parsed)); + } catch { + return value; + } +} + +function relationshipForResult( + result: BilingualSearchResult, + relationships: readonly RelationshipProjection[], +): RelationshipProjection | null { + return relationships.find((relationship) => ( + relationship.id === result.contactId + || (Boolean(result.conversationId) && relationship.sessionKey === result.conversationId) + )) || null; +} + +export function BilingualSearchPanel({ + relationships, + reducedMotion, + onSelectRelationship, + onNavigateRelationship, +}: BilingualSearchPanelProps): React.JSX.Element { + const [expanded, setExpanded] = useState(false); + const [query, setQuery] = useState(""); + const [results, setResults] = useState({ query: "", contacts: [], messages: [] }); + const [searchState, setSearchState] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [status, setStatus] = useState("Search messages in original text or Chinese translation."); + const [activeMessageId, setActiveMessageId] = useState(""); + const [activeJob, setActiveJob] = useState(null); + const [jobTransportError, setJobTransportError] = useState(""); + const searchSequence = useRef(0); + const translationSequence = useRef(0); + const latestQuery = useRef(""); + const pollTimer = useRef | null>(null); + + useEffect(() => { + latestQuery.current = query; + }, [query]); + + const activeJobStatus = normalizedStatus(activeJob); + const exactNavigationAvailableByMessage = useMemo(() => { + const availability = new Map(); + for (const result of results.messages) { + const relationship = relationshipForResult(result, relationships); + availability.set( + result.messageId, + Boolean(relationship?.matrixPermalink?.trim() || relationship?.matrixRoomId?.trim()), + ); + } + return availability; + }, [relationships, results.messages]); + + const runSearch = async (nextQuery: string): Promise => { + const trimmed = nextQuery.trim(); + const sequence = ++searchSequence.current; + if (!trimmed) { + setSearchState("idle"); + setResults({ query: "", contacts: [], messages: [] }); + setStatus("Search messages in original text or Chinese translation."); + return; + } + + setSearchState("loading"); + setStatus(`Searching for “${trimmed}”…`); + try { + const next = await searchWorkspace(trimmed); + if (sequence !== searchSequence.current) return; + setResults(next); + setSearchState("ready"); + const count = next.messages.length + next.contacts.length; + setStatus(count ? `${count} search results ready.` : `No results for “${trimmed}”.`); + } catch (error) { + if (sequence !== searchSequence.current) return; + setSearchState("error"); + setStatus(errorText(error, "Search is temporarily unavailable.")); + } + }; + + useEffect(() => { + if (!expanded) return undefined; + const trimmed = query.trim(); + if (!trimmed) { + searchSequence.current += 1; + setSearchState("idle"); + setResults({ query: "", contacts: [], messages: [] }); + setStatus("Search messages in original text or Chinese translation."); + return undefined; + } + + const timer = setTimeout(() => { + void runSearch(trimmed); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [expanded, query]); + + useEffect(() => { + if (pollTimer.current) { + clearTimeout(pollTimer.current); + pollTimer.current = null; + } + if (!activeJob || !ACTIVE_JOB_STATES.has(activeJobStatus)) return undefined; + + let disposed = false; + let failures = 0; + const jobId = activeJob.id; + const schedulePoll = (delay = JOB_POLL_MS): void => { + pollTimer.current = setTimeout(async () => { + try { + const next = await readTranslationJob(jobId); + if (disposed) return; + failures = 0; + setActiveJob(next); + setJobTransportError(""); + const nextStatus = normalizedStatus(next); + setStatus(`Translation ${nextStatus || "updated"}: ${Math.round(next.progress)}%.`); + if (ACTIVE_JOB_STATES.has(nextStatus)) { + schedulePoll(); + } else if (nextStatus === "success" && latestQuery.current.trim()) { + await runSearch(latestQuery.current); + } + } catch (error) { + if (disposed) return; + failures += 1; + setJobTransportError(errorText(error, "Translation status is temporarily unavailable.")); + if (failures >= MAX_POLL_FAILURES) { + setStatus("Translation status polling paused after repeated connection failures."); + return; + } + schedulePoll(Math.min(JOB_POLL_MS * (2 ** failures), MAX_POLL_BACKOFF_MS)); + } + }, delay); + }; + + schedulePoll(); + return () => { + disposed = true; + if (pollTimer.current) { + clearTimeout(pollTimer.current); + pollTimer.current = null; + } + }; + }, [activeJob?.id, activeJobStatus]); + + const startTranslation = async (messageId: string): Promise => { + const sequence = ++translationSequence.current; + setActiveMessageId(messageId); + setActiveJob(null); + setJobTransportError(""); + setStatus("Creating translation task…"); + try { + const job = await createTranslationJob(messageId); + if (sequence !== translationSequence.current) return; + setActiveJob(job); + setStatus(`Translation ${normalizedStatus(job) || "queued"}: ${Math.round(job.progress)}%.`); + } catch (error) { + if (sequence !== translationSequence.current) return; + setActiveJob(null); + setJobTransportError(errorText(error, "Unable to create translation task.")); + setStatus(errorText(error, "Unable to create translation task.")); + } + }; + + const cancelActiveJob = async (): Promise => { + if (!activeJob?.id || !activeJob.cancellable) return; + try { + const job = await cancelTranslationJob(activeJob.id); + setActiveJob(job); + setJobTransportError(""); + setStatus("Translation cancelled."); + } catch (error) { + setJobTransportError(errorText(error, "Unable to cancel translation task.")); + } + }; + + const retryActiveJob = async (): Promise => { + if (!activeJob?.id || !RETRYABLE_JOB_STATES.has(activeJobStatus)) return; + try { + const job = await retryTranslationJob(activeJob.id); + setActiveJob(job); + setActiveMessageId(job.messageId || activeMessageId); + setJobTransportError(""); + setStatus(`Translation ${normalizedStatus(job) || "queued"}: ${Math.round(job.progress)}%.`); + } catch (error) { + setJobTransportError(errorText(error, "Unable to retry translation task.")); + } + }; + + const navigateResult = async (result: BilingualSearchResult): Promise => { + const relationship = relationshipForResult(result, relationships); + if (!relationship) { + setStatus("This result has no available relationship context yet."); + return; + } + + const exactNavigationAvailable = Boolean( + relationship.matrixPermalink?.trim() || relationship.matrixRoomId?.trim(), + ); + let navigationError = ""; + if (exactNavigationAvailable && onNavigateRelationship) { + try { + const navigated = await onNavigateRelationship(relationship); + if (navigated) { + setStatus("Opened the authoritative Element conversation."); + return; + } + } catch (error) { + navigationError = errorText(error, "Element navigation is unavailable."); + } + } + + onSelectRelationship(relationship.id); + setStatus(navigationError + ? `Opened relationship context. Element navigation failed: ${navigationError}` + : "Opened relationship context. Exact Element message navigation is unavailable for this result."); + }; + + const clearSearch = (): void => { + searchSequence.current += 1; + setQuery(""); + setResults({ query: "", contacts: [], messages: [] }); + setSearchState("idle"); + setStatus("Search cleared."); + }; + + return ( +
+
+ + {expanded && query ? ( + + ) : null} +
+ + {expanded ? ( +
+ + +
+ {status} + {searchState === "loading" ? Searching… : null} +
+ + {jobTransportError ? ( +
+ Translation status unavailable + {jobTransportError} +
+ ) : null} + + {activeJob ? ( +
+
+ Translation task + {activeJobStatus || activeJob.durableState || "unknown"} +
+ + {activeJob.progress}% + +
+ {Math.round(activeJob.progress)}% + {activeJob.durableState ? {activeJob.durableState} : null} + {activeJob.errorCode ? {activeJob.errorCode} : null} +
+ {activeJob.error ?

{activeJob.error}

: null} +
+ {activeJob.cancellable ? ( + + ) : null} + {RETRYABLE_JOB_STATES.has(activeJobStatus) ? ( + + ) : null} +
+
+ ) : null} + + {searchState === "ready" && results.contacts.length ? ( +
+

People

+
+ {results.contacts.map((contact) => { + const relationship = relationships.find((row) => row.id === contact.contactId); + return ( + + ); + })} +
+
+ ) : null} + + {searchState === "ready" && results.messages.length ? ( +
    + {results.messages.map((result) => { + const exactNavigationAvailable = exactNavigationAvailableByMessage.get(result.messageId) === true; + const selectedJob = activeMessageId === result.messageId ? activeJob : null; + const hasTranslation = Boolean(result.translatedZh.trim()); + return ( +
  1. +
    +
    + {result.contactName || result.contactId || "Conversation"} + {[result.platform, formatTime(result.sentAt)].filter(Boolean).join(" · ")} +
    + + {exactNavigationAvailable ? "Element link" : "Relationship context"} + +
    + +
    + {!hasTranslation && !selectedJob ? ( + + ) : null} + {!exactNavigationAvailable ? Exact message jump unavailable : null} +
    +
  2. + ); + })} +
+ ) : null} + + {searchState === "ready" && !results.contacts.length && !results.messages.length ? ( +
+ No matching messages + Try a name, original phrase, or Chinese translation. +
+ ) : null} + + {searchState === "error" ? ( +
+ Search unavailable + Your query is preserved. Edit it or press Enter to retry. +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/integration/element-module/src/product-experience/ProductExperienceShell.css b/integration/element-module/src/product-experience/ProductExperienceShell.css index 7ce232579..75adf1855 100644 --- a/integration/element-module/src/product-experience/ProductExperienceShell.css +++ b/integration/element-module/src/product-experience/ProductExperienceShell.css @@ -522,6 +522,231 @@ color: var(--yance-muted); } +.yance-product-shell .yance-bilingual-search { + display: grid; + gap: var(--yance-space-2); + margin-bottom: var(--yance-space-4); + padding: var(--yance-space-2); + border: 1px solid var(--yance-border); + border-radius: var(--yance-radius-md); + background: var(--yance-surface); + box-shadow: 0 8px 28px rgb(10 18 32 / 5%); +} + +.yance-product-shell .yance-bilingual-search__topline, +.yance-product-shell .yance-bilingual-search__result-head, +.yance-product-shell .yance-bilingual-search__job-heading, +.yance-product-shell .yance-bilingual-search__job-meta, +.yance-product-shell .yance-bilingual-search__job-actions, +.yance-product-shell .yance-bilingual-search__result-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--yance-space-2); +} + +.yance-product-shell .yance-bilingual-search__toggle, +.yance-product-shell .yance-bilingual-search__quiet-action, +.yance-product-shell .yance-bilingual-search__contact, +.yance-product-shell .yance-bilingual-search__result-actions button, +.yance-product-shell .yance-bilingual-search__job-actions button { + min-height: 34px; + border: 1px solid var(--yance-border); + border-radius: var(--yance-radius-pill); + color: inherit; + background: var(--yance-surface-raised); + font: inherit; + cursor: pointer; +} + +.yance-product-shell .yance-bilingual-search__toggle { + display: inline-flex; + align-items: center; + gap: var(--yance-space-2); + padding: 6px 11px; + font-size: 0.78rem; + font-weight: 680; +} + +.yance-product-shell .yance-bilingual-search__quiet-action, +.yance-product-shell .yance-bilingual-search__result-actions button, +.yance-product-shell .yance-bilingual-search__job-actions button { + padding: 5px 10px; + font-size: 0.74rem; +} + +.yance-product-shell .yance-bilingual-search__panel { + display: grid; + gap: var(--yance-space-3); + min-height: 72px; + padding: var(--yance-space-2); +} + +.yance-product-shell .yance-bilingual-search__field { + display: grid; + gap: var(--yance-space-1); +} + +.yance-product-shell .yance-bilingual-search__label, +.yance-product-shell .yance-bilingual-search__status, +.yance-product-shell .yance-bilingual-search__job-meta, +.yance-product-shell .yance-bilingual-search__nav-state, +.yance-product-shell .yance-bilingual-search__result-actions, +.yance-product-shell .yance-bilingual-search__result-head span, +.yance-product-shell .yance-bilingual-search__original-label, +.yance-product-shell .yance-bilingual-search__contact small { + color: var(--yance-muted); + font-size: 0.72rem; +} + +.yance-product-shell .yance-bilingual-search__field input { + box-sizing: border-box; + width: 100%; + min-height: 40px; + padding: 8px 11px; + border: 1px solid var(--yance-border); + border-radius: var(--yance-radius-sm); + color: inherit; + background: var(--yance-surface); + font: inherit; +} + +.yance-product-shell .yance-bilingual-search__status { + min-height: 20px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--yance-space-2); +} + +.yance-product-shell .yance-bilingual-search__loading { + color: var(--yance-accent); +} + +.yance-product-shell .yance-bilingual-search__notice, +.yance-product-shell .yance-bilingual-search__job, +.yance-product-shell .yance-bilingual-search__empty { + display: grid; + gap: var(--yance-space-2); + padding: var(--yance-space-3); + border: 1px solid var(--yance-border); + border-radius: var(--yance-radius-sm); + background: var(--yance-surface-raised); +} + +.yance-product-shell .yance-bilingual-search__notice span, +.yance-product-shell .yance-bilingual-search__empty span, +.yance-product-shell .yance-bilingual-search__job-error { + margin: 0; + color: var(--yance-muted); + font-size: 0.75rem; +} + +.yance-product-shell .yance-bilingual-search__job progress { + width: 100%; + height: 7px; + accent-color: var(--yance-accent); +} + +.yance-product-shell .yance-bilingual-search__contacts, +.yance-product-shell .yance-bilingual-search__results { + display: grid; + gap: var(--yance-space-2); +} + +.yance-product-shell .yance-bilingual-search__contacts h3 { + margin: 0; + font-size: 0.78rem; +} + +.yance-product-shell .yance-bilingual-search__contact-list { + display: flex; + flex-wrap: wrap; + gap: var(--yance-space-2); +} + +.yance-product-shell .yance-bilingual-search__contact { + display: inline-flex; + align-items: center; + gap: var(--yance-space-2); + max-width: 100%; + padding: 5px 10px; +} + +.yance-product-shell .yance-bilingual-search__contact:disabled { + cursor: default; + opacity: 0.55; +} + +.yance-product-shell .yance-bilingual-search__results { + margin: 0; + padding: 0; + list-style: none; +} + +.yance-product-shell .yance-bilingual-search__result { + display: grid; + gap: var(--yance-space-2); + padding: var(--yance-space-3); + border: 1px solid var(--yance-border); + border-radius: var(--yance-radius-md); + background: var(--yance-surface-raised); +} + +.yance-product-shell .yance-bilingual-search__result-head > div { + display: grid; + min-width: 0; + gap: 2px; +} + +.yance-product-shell .yance-bilingual-search__nav-state { + flex: 0 0 auto; + padding: 4px 7px; + border-radius: var(--yance-radius-pill); + background: color-mix(in srgb, var(--yance-accent) 9%, transparent); +} + +.yance-product-shell .yance-bilingual-search__message { + display: grid; + gap: var(--yance-space-1); + width: 100%; + padding: var(--yance-space-2) 0; + border: 0; + color: inherit; + background: transparent; + text-align: left; + font: inherit; + cursor: pointer; +} + +.yance-product-shell .yance-bilingual-search__original { + overflow-wrap: anywhere; + line-height: 1.45; +} + +.yance-product-shell .yance-bilingual-search__translation { + display: grid; + gap: 2px; + margin-top: var(--yance-space-1); + padding: var(--yance-space-2) var(--yance-space-3); + border-left: 2px solid color-mix(in srgb, var(--yance-accent) 50%, var(--yance-border)); + border-radius: 0 var(--yance-radius-sm) var(--yance-radius-sm) 0; + background: color-mix(in srgb, var(--yance-accent) 7%, transparent); + line-height: 1.45; +} + +.yance-product-shell .yance-bilingual-search__translation > span:first-child { + color: var(--yance-muted); + font-size: 0.68rem; + font-weight: 650; +} + +.yance-bilingual-search button:focus-visible, +.yance-bilingual-search input:focus-visible { + outline: 3px solid var(--yance-focus); + outline-offset: 2px; +} + .yance-product-shell button:focus-visible, .yance-product-shell select:focus-visible, .yance-product-shell textarea:focus-visible, @@ -571,6 +796,13 @@ max-height: calc(100vh - 16px); border-radius: var(--yance-radius-md); } + + .yance-product-shell .yance-bilingual-search__result-head, + .yance-product-shell .yance-bilingual-search__result-actions, + .yance-product-shell .yance-bilingual-search__job-heading { + align-items: flex-start; + flex-direction: column; + } } @media (prefers-reduced-motion: reduce) { @@ -582,6 +814,11 @@ animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; } + + .yance-product-shell .yance-bilingual-search, + .yance-product-shell .yance-bilingual-search * { + scroll-behavior: auto !important; + } } .yance-product-shell[data-reduced-motion] *, diff --git a/integration/element-module/src/product-experience/ProductExperienceShell.tsx b/integration/element-module/src/product-experience/ProductExperienceShell.tsx index b747a8816..e8645b6ab 100644 --- a/integration/element-module/src/product-experience/ProductExperienceShell.tsx +++ b/integration/element-module/src/product-experience/ProductExperienceShell.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { LearningWorkspace } from "../LearningWorkspace"; import { AnimatePresence, motion } from "motion/react"; +import { BilingualSearchPanel } from "./BilingualSearchPanel"; import { PeopleSurface } from "./PeopleSurface"; import { RelationshipAssistant } from "./RelationshipAssistant"; import { RelationshipOverlayHost } from "./RelationshipOverlayHost"; @@ -21,7 +22,11 @@ import type { } from "./experienceTypes"; import "./ProductExperienceShell.css"; -export function ProductExperienceShell(): React.JSX.Element { +type ProductExperienceShellProps = { + navigateSearchResult?: (relationship: RelationshipProjection) => Promise; +}; + +export function ProductExperienceShell({ navigateSearchResult }: ProductExperienceShellProps): React.JSX.Element { const [relationships, setRelationships] = useState([]); const [loading, setLoading] = useState(true); const [status, setStatus] = useState("Loading relationships"); @@ -83,6 +88,13 @@ export function ProductExperienceShell(): React.JSX.Element { >
{status}
+ + {!selectedRelationship ? ( Promise>; + storeSearchWorkspace: (input: { query: string; limit?: number }) => Promise>; + storeCreateTranslationJob: (input: { messageId: string; force?: boolean; forceNew?: boolean; timeoutMs?: number }) => Promise>; + storeGetTranslationJob: (input: { jobId: string }) => Promise>; + storeCancelTranslationJob: (input: { jobId: string }) => Promise>; + storeRetryTranslationJob: (input: { jobId: string; timeoutMs?: number }) => Promise>; getParlantRelationshipGoal: (input: { contactId: string }) => Promise; upsertParlantRelationshipGoal: (input: { contactId: string; goalText: string }) => Promise; deleteParlantRelationshipGoal: (input: { contactId: string }) => Promise<{ deleted: boolean }>; @@ -49,6 +58,10 @@ function objectRecord(value: unknown): Record { return value && typeof value === "object" ? value as Record : {}; } +function objectArray(value: unknown): Record[] { + return Array.isArray(value) ? value.map(objectRecord) : []; +} + function text(value: unknown): string { return typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; } @@ -58,6 +71,10 @@ function optionalText(value: unknown): string | undefined { return normalized || undefined; } +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map(text).filter(Boolean) : []; +} + function asTimestamp(value: unknown): string | undefined { const candidate = optionalText(value); if (!candidate) return undefined; @@ -65,6 +82,12 @@ function asTimestamp(value: unknown): string | undefined { return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; } +function bridgeUnavailable(operation: string): Error { + const error = new Error(`DESKTOP_PRODUCT_BRIDGE_UNAVAILABLE:${operation}`); + error.name = "ProductDesktopBridgeError"; + return error; +} + function emptyGoal(reasonCode = ""): RelationshipGoalProjection { return { available: !reasonCode, @@ -95,10 +118,77 @@ function relationshipFromEntry(key: string, value: unknown): RelationshipProject accountId, chatJid: optionalText(row.chatJid || row.jid), sessionKey: optionalText(row.sessionKey || row.sessionId), + matrixRoomId: optionalText(row.matrixRoomId), + matrixPermalink: optionalText(row.matrixPermalink), updatedAt: asTimestamp(row.updatedAt || row.lastInteractionAt || row.lastMessageAt || row.modifiedAt), }; } +function normalizeContactResult(value: unknown): WorkspaceContactSearchResult | null { + const row = objectRecord(value); + const contactId = text(row.contactId || row.id); + if (!contactId) return null; + return { + id: text(row.id || contactId), + contactId, + conversationId: text(row.conversationId), + name: text(row.name), + phone: text(row.phone), + platform: text(row.platform), + avatarUrl: text(row.avatarUrl), + tags: stringArray(row.tags), + }; +} + +function normalizeMessageResult(value: unknown): BilingualSearchResult | null { + const row = objectRecord(value); + const messageId = text(row.messageId || row.id); + if (!messageId) return null; + return { + id: text(row.id || messageId), + messageId, + conversationId: text(row.conversationId), + contactId: text(row.contactId), + contactName: text(row.contactName), + platform: text(row.platform), + text: text(row.text), + translatedZh: text(row.translatedZh), + sourceLanguage: text(row.sourceLanguage), + direction: text(row.direction), + messageType: text(row.messageType), + sentAt: text(row.sentAt), + rank: Number.isFinite(Number(row.rank)) ? Number(row.rank) : 0, + }; +} + +function normalizeTranslationJob(value: unknown): TranslationJobProjection { + const row = objectRecord(value); + const id = text(row.id || row.operationId); + if (!id) throw new Error("TRANSLATION_JOB_INVALID_RESPONSE"); + return { + id, + messageId: text(row.messageId), + conversationId: text(row.conversationId), + contactId: text(row.contactId), + status: text(row.status), + progress: Math.max(0, Math.min(100, Number.isFinite(Number(row.progress)) ? Number(row.progress) : 0)), + createdAt: text(row.createdAt), + startedAt: text(row.startedAt), + finishedAt: text(row.finishedAt), + errorCode: text(row.errorCode), + error: text(row.error), + retryOf: text(row.retryOf), + translationKey: text(row.translationKey), + sourceHash: text(row.sourceHash), + operationId: text(row.operationId || id), + generation: Number.isFinite(Number(row.generation)) ? Number(row.generation) : 0, + objectFingerprint: text(row.objectFingerprint), + durableState: text(row.durableState), + lifecyclePersisted: row.lifecyclePersisted !== false, + cancellable: row.cancellable === true, + }; +} + export async function loadRelationshipProjections(): Promise { const api = desktopApi(); if (!api || typeof api.storeSnapshot !== "function") return []; @@ -120,6 +210,66 @@ export async function loadRelationshipProjections(): Promise { + const api = desktopApi(); + if (!api || typeof api.storeSearchWorkspace !== "function") throw bridgeUnavailable("search-workspace"); + const normalizedQuery = query.trim(); + if (!normalizedQuery) return { query: "", contacts: [], messages: [] }; + const numericLimit = limit == null ? 80 : Number(limit); + const boundedLimit = Math.max(1, Math.min(200, Number.isFinite(numericLimit) ? numericLimit : 80)); + const payload = objectRecord(await api.storeSearchWorkspace({ + query: normalizedQuery, + limit: boundedLimit, + })); + return { + query: text(payload.query || normalizedQuery), + contacts: objectArray(payload.contacts).map(normalizeContactResult).filter((row): row is WorkspaceContactSearchResult => Boolean(row)), + messages: objectArray(payload.messages).map(normalizeMessageResult).filter((row): row is BilingualSearchResult => Boolean(row)), + }; +} + +export async function createTranslationJob( + messageId: string, + options: { force?: boolean; forceNew?: boolean; timeoutMs?: number } = {}, +): Promise { + const api = desktopApi(); + if (!api || typeof api.storeCreateTranslationJob !== "function") throw bridgeUnavailable("create-translation-job"); + const id = messageId.trim(); + if (!id) throw new Error("MESSAGE_ID_REQUIRED"); + const payload = objectRecord(await api.storeCreateTranslationJob({ messageId: id, ...options })); + return normalizeTranslationJob(payload.job || payload); +} + +export async function readTranslationJob(jobId: string): Promise { + const api = desktopApi(); + if (!api || typeof api.storeGetTranslationJob !== "function") throw bridgeUnavailable("get-translation-job"); + const id = jobId.trim(); + if (!id) throw new Error("TRANSLATION_JOB_ID_REQUIRED"); + const payload = objectRecord(await api.storeGetTranslationJob({ jobId: id })); + return normalizeTranslationJob(payload.job || payload); +} + +export async function cancelTranslationJob(jobId: string): Promise { + const api = desktopApi(); + if (!api || typeof api.storeCancelTranslationJob !== "function") throw bridgeUnavailable("cancel-translation-job"); + const id = jobId.trim(); + if (!id) throw new Error("TRANSLATION_JOB_ID_REQUIRED"); + const payload = objectRecord(await api.storeCancelTranslationJob({ jobId: id })); + return normalizeTranslationJob(payload.job || payload); +} + +export async function retryTranslationJob( + jobId: string, + options: { timeoutMs?: number } = {}, +): Promise { + const api = desktopApi(); + if (!api || typeof api.storeRetryTranslationJob !== "function") throw bridgeUnavailable("retry-translation-job"); + const id = jobId.trim(); + if (!id) throw new Error("TRANSLATION_JOB_ID_REQUIRED"); + const payload = objectRecord(await api.storeRetryTranslationJob({ jobId: id, ...options })); + return normalizeTranslationJob(payload.job || payload); +} + export async function loadRelationshipAssistant(contactId: string): Promise { const api = desktopApi(); const relationshipId = contactId.trim(); diff --git a/integration/element-module/src/product-experience/experienceTypes.ts b/integration/element-module/src/product-experience/experienceTypes.ts index 7330811c9..bf453e753 100644 --- a/integration/element-module/src/product-experience/experienceTypes.ts +++ b/integration/element-module/src/product-experience/experienceTypes.ts @@ -7,9 +7,67 @@ export type RelationshipProjection = { accountId?: string; chatJid?: string; sessionKey?: string; + matrixRoomId?: string; + matrixPermalink?: string; updatedAt?: string; }; +export type WorkspaceContactSearchResult = { + id: string; + contactId: string; + conversationId: string; + name: string; + phone: string; + platform: string; + avatarUrl: string; + tags: readonly string[]; +}; + +export type BilingualSearchResult = { + id: string; + messageId: string; + conversationId: string; + contactId: string; + contactName: string; + platform: string; + text: string; + translatedZh: string; + sourceLanguage: string; + direction: string; + messageType: string; + sentAt: string; + rank: number; +}; + +export type WorkspaceSearchProjection = { + query: string; + contacts: readonly WorkspaceContactSearchResult[]; + messages: readonly BilingualSearchResult[]; +}; + +export type TranslationJobProjection = { + id: string; + messageId: string; + conversationId: string; + contactId: string; + status: string; + progress: number; + createdAt: string; + startedAt: string; + finishedAt: string; + errorCode: string; + error: string; + retryOf: string; + translationKey: string; + sourceHash: string; + operationId: string; + generation: number; + objectFingerprint: string; + durableState: string; + lifecyclePersisted: boolean; + cancellable: boolean; +}; + export type RelationshipGoalProjection = { available: boolean; exists: boolean | null; diff --git a/tests/wp0/v21-product-experience-bilingual-search-translation-task-ux.test.js b/tests/wp0/v21-product-experience-bilingual-search-translation-task-ux.test.js new file mode 100644 index 000000000..f27da1491 --- /dev/null +++ b/tests/wp0/v21-product-experience-bilingual-search-translation-task-ux.test.js @@ -0,0 +1,198 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const PANEL_PATH = 'integration/element-module/src/product-experience/BilingualSearchPanel.tsx'; + +function read(relativePath) { + return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); +} + +function readOrEmpty(relativePath) { + const fullPath = path.join(ROOT, relativePath); + return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : ''; +} + +function allProductSource() { + const directory = path.join(ROOT, 'integration/element-module/src/product-experience'); + if (!fs.existsSync(directory)) return ''; + return fs.readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(?:ts|tsx)$/u.test(entry.name)) + .map((entry) => fs.readFileSync(path.join(entry.parentPath || entry.path || directory, entry.name), 'utf8')) + .join('\n'); +} + +test('landed backend already owns bilingual search and durable translation job lifecycle', () => { + const routes = read('backend/routes/store.js'); + const service = read('backend/services/messageTranslationService.js'); + + assert.match(routes, /router\.get\('\/search'/u); + assert.match(routes, /\/translations\/messages\/:messageId\/jobs/u); + assert.match(routes, /router\.get\('\/translations\/jobs\/:jobId'/u); + assert.match(routes, /router\.delete\('\/translations\/jobs\/:jobId'/u); + assert.match(routes, /\/translations\/jobs\/:jobId\/retry/u); + assert.match(service, /AsyncOperationLifecycleAuthority/u); + assert.match(service, /cancelJob\s*\(/u); + assert.match(service, /retryJob\s*\(/u); + assert.match(service, /durableState/u); + assert.match(service, /cancellable/u); +}); + +test('new bilingual Product component has one exact PRODUCT_WP0 route', () => { + const policy = JSON.parse(read('governance/layered-ci/wp0-routing-policy.json')); + assert.equal(policy.productExactPaths.includes(PANEL_PATH), true, `${PANEL_PATH} must be registered as an exact Product route`); + assert.equal(policy.productPrefixes.includes('integration/'), false, 'broad integration/ Product routing must remain forbidden'); + assert.equal(policy.productPrefixes.includes('integration/element-module/src/product-experience/'), false, 'broad Product Experience routing must remain forbidden'); +}); + +test('desktop bridge exposes only exact workspace-search and translation-job operations', async () => { + const bridge = require(path.join(ROOT, 'electron/r32StoreBridge.js')); + const preload = read('electron/preload.js'); + + assert.equal(bridge.CHANNELS.searchWorkspace, 'store:search-workspace'); + assert.equal(bridge.CHANNELS.createTranslationJob, 'store:create-translation-job'); + assert.equal(bridge.CHANNELS.getTranslationJob, 'store:get-translation-job'); + assert.equal(bridge.CHANNELS.cancelTranslationJob, 'store:cancel-translation-job'); + assert.equal(bridge.CHANNELS.retryTranslationJob, 'store:retry-translation-job'); + + const bridgeSource = read('electron/r32StoreBridge.js'); + assert.match(bridgeSource, /\/api\/r32\/store\/search/u); + assert.match(bridgeSource, /\/api\/r32\/store\/translations\/messages\/\$\{encodeURIComponent\(messageId\)\}\/jobs/u); + assert.match(bridgeSource, /\/api\/r32\/store\/translations\/jobs\/\$\{encodeURIComponent\(jobId\)\}/u); + assert.match(bridgeSource, /method:\s*'DELETE'/u); + assert.match(bridgeSource, /\/retry/u); + + for (const method of [ + 'storeSearchWorkspace', + 'storeCreateTranslationJob', + 'storeGetTranslationJob', + 'storeCancelTranslationJob', + 'storeRetryTranslationJob' + ]) assert.match(preload, new RegExp(`\\b${method}\\b`, 'u'), `${method} must be exposed through contextBridge`); + + const exposure = preload.match( + /contextBridge\.exposeInMainWorld\('yanceDesktop',\s*Object\.freeze\(\{([\s\S]*?)\}\)\);/u + )?.[1]; + assert.ok(exposure, 'yanceDesktop must be exposed as a frozen capability object'); + assert.equal( + [...exposure.matchAll(/\bipcRenderer\b(?!\s*\.)/gu)].length, + 0, + 'the yanceDesktop capability object must never expose the raw ipcRenderer' + ); + + const handlers = new Map(); + const requests = []; + const ipcMain = { + handle(channel, handler) { handlers.set(channel, handler); }, + on() {}, + removeHandler(channel) { handlers.delete(channel); }, + removeListener() {} + }; + const dispose = bridge.installR32StoreBridge({ + ipcMain, + apiRequest: async (url, options) => { + requests.push({ url, options }); + return {}; + } + }); + try { + const searchHandler = handlers.get(bridge.CHANNELS.searchWorkspace); + assert.equal(typeof searchHandler, 'function'); + + await searchHandler({}, { query: 'needle', limit: 'not-a-number', url: 'https://example.invalid/' }); + assert.match(requests.at(-1).url, /^\/api\/r32\/store\/search\?/u); + assert.match(requests.at(-1).url, /(?:^|[?&])limit=80(?:&|$)/u); + assert.doesNotMatch(requests.at(-1).url, /example\.invalid/u); + + await searchHandler({}, { query: 'zero', limit: 0 }); + assert.match(requests.at(-1).url, /(?:^|[?&])limit=1(?:&|$)/u); + + await searchHandler({}, { query: 'high', limit: 999 }); + assert.match(requests.at(-1).url, /(?:^|[?&])limit=200(?:&|$)/u); + } finally { + dispose(); + } +}); + +test('Product projection composes the desktop authority and Element public navigation only', () => { + const projection = read('integration/element-module/src/product-experience/experienceProjection.ts'); + const index = read('integration/element-module/src/index.tsx'); + const workspace = read('integration/element-module/src/YanceWorkspace.tsx'); + const product = allProductSource(); + + for (const fn of [ + 'searchWorkspace', + 'createTranslationJob', + 'readTranslationJob', + 'cancelTranslationJob', + 'retryTranslationJob' + ]) assert.match(projection, new RegExp(`export\\s+async\\s+function\\s+${fn}\\b`, 'u'), `${fn} must be a typed Product projection wrapper`); + + assert.match(projection, /const\s+numericLimit\s*=\s*limit\s*==\s*null\s*\?\s*80\s*:\s*Number\(limit\)/u); + assert.match(projection, /Number\.isFinite\(numericLimit\)/u); + assert.doesNotMatch(projection, /Number\(limit\s*\|\|\s*80\)/u); + assert.match(index, /api\.navigation\.(?:openRoom|toMatrixToLink)/u); + assert.match(workspace, /ProductExperienceShell/u); + assert.match(workspace, /navigate/u); + + assert.doesNotMatch(product, /\bfetch\s*\(/u, 'Element Product code must not become a renderer-direct local API client'); + assert.doesNotMatch(product, /querySelector\([^)]*(?:timeline|composer)|mx_RoomView|mx_MessageComposer|RightPanelStore/u); + assert.doesNotMatch(product, /chatJid\s*(?:as|:)\s*(?:matrix|room)|sessionKey\s*(?:as|:)\s*(?:matrix|room)/iu, 'provider identifiers must not be re-labeled as Matrix identities'); +}); + +test('bilingual search panel renders evidence and truthful bounded translation lifecycle UX with Product polish seams', () => { + assert.equal(fs.existsSync(path.join(ROOT, PANEL_PATH)), true, `${PANEL_PATH} must exist`); + const panel = readOrEmpty(PANEL_PATH); + const shell = read('integration/element-module/src/product-experience/ProductExperienceShell.tsx'); + const css = read('integration/element-module/src/product-experience/ProductExperienceShell.css'); + + assert.match(shell, /BilingualSearchPanel/u); + assert.match(panel, /translatedZh/u); + assert.match(panel, /(?:result|message)\.text/u); + assert.match(panel, /aria-live=["']polite["']/u); + assert.match(panel, /\s*Cancel\s*\s*Retry\s*=\s*MAX_POLL_FAILURES/u); + assert.match(panel, /2\s*\*\*\s*failures/u); + assert.match(panel, /setActiveMessageId\(messageId\)[\s\S]{0,140}setActiveJob\(null\)/u); + assert.match(panel, /let\s+navigationError\s*=\s*""/u); + assert.match(panel, /navigationError\s*=\s*errorText/u); + assert.doesNotMatch(panel, /AbortController\([^)]*\).*cancelTranslationJob/su, 'unmounting Product UI must not implicitly cancel the authoritative backend job'); + + assert.match(css, /\.yance-product-shell\s+\.yance-bilingual-search/u); + assert.match(css, /\.yance-product-shell\s+\.yance-bilingual-search[^{}]*\{[^}]*var\(--yance-/su); + assert.match(css, /yance-bilingual-search__field input[\s\S]{0,320}background:\s*var\(--yance-surface\)/u); + assert.match(css, /yance-bilingual[^{}]*:focus-visible/u); + assert.match(css, /prefers-reduced-motion/u); + assert.doesNotMatch(css, /--(?:bilingual|search|translation)-/u, 'new Product UX must reuse the existing --yance-* token namespace'); +}); + +test('newer translation requests cannot be overwritten by slower older create-job responses', () => { + const panel = readOrEmpty(PANEL_PATH); + assert.match(panel, /const\s+translationSequence\s*=\s*useRef\(0\)/u); + assert.match(panel, /const\s+sequence\s*=\s*\+\+translationSequence\.current/u); + assert.match(panel, /if\s*\(sequence\s*!==\s*translationSequence\.current\)\s*return/u); +}); + +test('translation completion refreshes the latest query instead of an effect-captured stale query', () => { + const panel = readOrEmpty(PANEL_PATH); + assert.match(panel, /const\s+latestQuery\s*=\s*useRef\(""\)/u); + assert.match(panel, /latestQuery\.current\s*=\s*query/u); + assert.match(panel, /runSearch\(latestQuery\.current\)/u); + assert.doesNotMatch(panel, /nextStatus\s*===\s*"success"\s*&&\s*query\.trim\(\)[\s\S]{0,160}runSearch\(query\)/u); +});