From 05dce38c2a0766867e3ea4d9880338251710a6ae Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 10:01:19 -0400 Subject: [PATCH] Preserve imported conversation dates and display provider logos --- .../external_conversation_import.md | 7 + src/backend/services/chat_history_service.py | 37 +++- .../external_conversation_import_service.py | 15 +- .../static/images/providers/OCTICONS-LICENSE | 21 ++ .../static/images/providers/README.md | 10 + .../static/images/providers/claude.svg | 3 + .../static/images/providers/codex.png | Bin 0 -> 9974 bytes .../static/images/providers/copilot.svg | 1 + .../static/images/providers/gemini.svg | 1 + .../web/von_interface/static/js/chatTab.js | 10 +- .../components/importedConversationAvatar.js | 27 +++ .../web/von_interface/static/styles.css | 21 ++ ...xternal_conversation_timestamp_fallback.py | 197 ++++++++++++++++++ tests/browser/importedConversationAvatars.cjs | 45 ++++ 14 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 src/frontend/web/von_interface/static/images/providers/OCTICONS-LICENSE create mode 100644 src/frontend/web/von_interface/static/images/providers/README.md create mode 100644 src/frontend/web/von_interface/static/images/providers/claude.svg create mode 100644 src/frontend/web/von_interface/static/images/providers/codex.png create mode 100644 src/frontend/web/von_interface/static/images/providers/copilot.svg create mode 100644 src/frontend/web/von_interface/static/images/providers/gemini.svg create mode 100644 src/frontend/web/von_interface/static/js/components/importedConversationAvatar.js create mode 100644 tests/backend/test_external_conversation_timestamp_fallback.py create mode 100644 tests/browser/importedConversationAvatars.cjs diff --git a/docs/engineering/external_conversation_import.md b/docs/engineering/external_conversation_import.md index 19728df9..e958ea91 100644 --- a/docs/engineering/external_conversation_import.md +++ b/docs/engineering/external_conversation_import.md @@ -48,6 +48,13 @@ The capability has five layers: 3. Only ordinary user-visible human and assistant text is projected into the actor-scoped chat-history store. The projection carries external actor and event provenance and is marked read-only. + Recorded message dates are preserved. Missing message dates use the source + file's last-modified time, labelled `source_file_modified`; the original + source timestamp remains null and import time is recorded separately. + Re-import repairs older import-time fallbacks only for unchanged events. + Known providers use local Codex, Claude Code, Copilot and Gemini brand assets + in conversation rows and assistant avatars; these do not imply a registered + Von participant identity. 4. Continue creates a new native conversation with a lineage receipt and a copy of the visible history. The source snapshot remains unchanged. 5. Machine-wide discovery and execution use a persisted batch/item controller. diff --git a/src/backend/services/chat_history_service.py b/src/backend/services/chat_history_service.py index af990463..a3345c91 100644 --- a/src/backend/services/chat_history_service.py +++ b/src/backend/services/chat_history_service.py @@ -4853,6 +4853,7 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str: } divergence: Dict[str, Any] | None = None + corrected_fallback_timestamp_count = 0 appended_history = prepared_history action = "new" if existing is not None: @@ -4920,6 +4921,32 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str: else: action = "refreshed" + # A missing source timestamp is part of the event fingerprint; its + # display fallback is not. Repair only verified, unchanged events. + if not divergence: + repaired_history = [] + for entry in existing_history: + incoming = incoming_by_id.get(entry.get("external_event_id")) + if ( + incoming is not None + and "external_source_timestamp_utc" in entry + and entry["external_source_timestamp_utc"] is None + and incoming.get("external_timestamp_source") == "source_file_modified" + and _coerce_datetime(incoming.get("timestamp")) is not None + and ( + entry.get("timestamp") != incoming["timestamp"] + or entry.get("external_timestamp_source") != "source_file_modified" + ) + ): + entry = { + **entry, + "timestamp": incoming["timestamp"], + "external_timestamp_source": "source_file_modified", + } + corrected_fallback_timestamp_count += 1 + repaired_history.append(entry) + existing_history = repaired_history + combined_history = [*existing_history, *appended_history] stored_manifest["synchronisation"] = { "schema_version": "external_conversation_synchronisation.v1", @@ -4927,6 +4954,7 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str: "checked_at_utc": now.isoformat(), "appended_message_count": len(appended_history), "retained_message_count": len(existing_history), + "corrected_fallback_timestamp_count": corrected_fallback_timestamp_count, "divergence": divergence, } search_segments = [ @@ -4974,7 +5002,13 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str: query[f"{EXTERNAL_CONVERSATION_IMPORT_FIELD}.package_sha256"] = ( existing_manifest.get("package_sha256") ) - if appended_history: + if corrected_fallback_timestamp_count: + # Keep the same package compare-and-swap and avoid conflicting + # $push/$set updates to the history array. + set_fields["history"] = combined_history + set_fields["created_at"] = first_message_at + set_on_insert.pop("created_at", None) + elif appended_history: update["$push"] = {"history": {"$each": appended_history}} try: result = chat_history_coll.update_one( @@ -5041,6 +5075,7 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str: "action": action, "session_id": session_id, "appended_message_count": len(appended_history), + "corrected_fallback_timestamp_count": corrected_fallback_timestamp_count, "divergence": divergence, "canonical_read_back": { "session_id": session_id, diff --git a/src/backend/services/external_conversation_import_service.py b/src/backend/services/external_conversation_import_service.py index 4f7dfcb5..35d26f21 100644 --- a/src/backend/services/external_conversation_import_service.py +++ b/src/backend/services/external_conversation_import_service.py @@ -30,7 +30,7 @@ EXTERNAL_CONVERSATION_PACKAGE_SCHEMA_VERSION = "external_conversation_package.v1" -EXTERNAL_CONVERSATION_PARSER_VERSION = "2026-09-16.2" +EXTERNAL_CONVERSATION_PARSER_VERSION = "2026-09-16.3" EXTERNAL_CONVERSATION_ORIGIN_KIND = "external_conversation_import" EXTERNAL_CONVERSATION_LINEAGE_SCHEMA_VERSION = "conversation_lineage.v1" MAX_EXTERNAL_CONVERSATION_SOURCE_BYTES = 32 * 1024 * 1024 @@ -1460,6 +1460,8 @@ def parse_external_conversation_file( def _history_projection( package: ExternalConversationPackage, + *, + source_file_modified_at: datetime, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: history: list[dict[str, Any]] = [] total_chars = 0 @@ -1489,7 +1491,10 @@ def _history_projection( { "role": event.source_role, "content": projected, - "timestamp": timestamp or datetime.now(timezone.utc), + "timestamp": timestamp or source_file_modified_at, + "external_timestamp_source": ( + "source_message" if timestamp else "source_file_modified" + ), "external_event_id": event.event_id, "external_event_kind": event.event_kind, "external_source_role": event.source_role, @@ -1621,7 +1626,10 @@ def import_external_conversation_file( source_account=package.source_account, source_workspace=package.source_workspace, ) - history, projection_report = _history_projection(package) + source_file_modified_at = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + history, projection_report = _history_projection( + package, source_file_modified_at=source_file_modified_at + ) if not history: raise ExternalConversationImportError( "The source contains no user-visible human or assistant messages.", @@ -1717,6 +1725,7 @@ def import_external_conversation_file( "parser_version": package.parser_version, "source_created_at_utc": package.source_created_at_utc, "source_updated_at_utc": package.source_updated_at_utc, + "source_file_modified_at_utc": source_file_modified_at.isoformat(), "parent_conversation_id": package.parent_conversation_id, "raw_document_concept_id": raw_document_concept_id, "raw_file_copy_concept_id": raw_record_result.get("file_copy_concept_id"), diff --git a/src/frontend/web/von_interface/static/images/providers/OCTICONS-LICENSE b/src/frontend/web/von_interface/static/images/providers/OCTICONS-LICENSE new file mode 100644 index 00000000..aa1fa80e --- /dev/null +++ b/src/frontend/web/von_interface/static/images/providers/OCTICONS-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/frontend/web/von_interface/static/images/providers/README.md b/src/frontend/web/von_interface/static/images/providers/README.md new file mode 100644 index 00000000..c220f76e --- /dev/null +++ b/src/frontend/web/von_interface/static/images/providers/README.md @@ -0,0 +1,10 @@ +# Imported conversation provider logos + +Used only to identify the source of imported conversations. Marks belong to their respective owners; they do not indicate endorsement. Assets are served locally. + +Retrieved 16 September 2026: + +- `codex.png`: unmodified Codex app mark bundled with OpenAI VS Code extension `openai.chatgpt-26.908.40401-darwin-arm64`, asset `codex-app-ga-logo-3e5209898ca3.png`. +- `claude.svg`: https://claude.ai/favicon.svg +- `copilot.svg`: https://raw.githubusercontent.com/primer/octicons/main/icons/copilot-24.svg (GitHub Octicons, MIT; see `OCTICONS-LICENSE`). +- `gemini.svg`: https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg, linked by https://gemini.google.com/. diff --git a/src/frontend/web/von_interface/static/images/providers/claude.svg b/src/frontend/web/von_interface/static/images/providers/claude.svg new file mode 100644 index 00000000..26d103d0 --- /dev/null +++ b/src/frontend/web/von_interface/static/images/providers/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/web/von_interface/static/images/providers/codex.png b/src/frontend/web/von_interface/static/images/providers/codex.png new file mode 100644 index 0000000000000000000000000000000000000000..7ef8986eb0a2ea7db60f7eaf5851df2f536da6b3 GIT binary patch literal 9974 zcmVAvGSNxym}rulOuS;|PVzZdM;#wAaiZqCYJ7~N@r9x&Dg;5nfPeuS zZIA}~Nx%E_IaPb#y?5=sYgP5>H=xef>-(y!PSvhmRqNktt$(k*s~TWfhGkfWWmtw~ zScYX-hUHKSQA;;rjFCRh`)?y(wTIE5FtIFKw#<6#{gD;pjv05-j8jfJb3`I*;@I+0 z6UQDkqA=M6ojZ3{H&UAT{L@XF|GdAsZEfS$_qMHCb$5NFE(%{``KkaU?!)4XCw`pw zt-GdP_BYpmf7)>)FDor3V@q-Y9IoaBFX46pq+JFUArOnJ6S4rBIsw|dz}vDdy=~10 zl@A}-{LB+iK6yt=wC=B)d=&t)!Efd4x|`R|`{q@%@1HTnnU%+F$aaApr~=r%hmqO_ z2JNjDq&PqxFHN}0WBklSfE0-&-Uyp=2W|HpqlasNN9U-_#B zd>!~2$(IR`xVBK5d&bgV7T8`&+`|tJ1>XAQ56=QISU}W1gu@$hF*BF=I6Kl{g{7I*LB1-;cFsa7C^@B z{NT=A|Kr-Lk9u_KD1gs4L3-_ah&6JhI$Vvh7%wD%QBg?%)SC#?@VM$-*ROnHIna4$ z0JTcMd216?J@fF>^PYb6%6BQOE2Jpy3o4)bDFJ`_00U43^^queg#@VqW zZWkBtrq4aMOjsDraNht&*hL;`fDAalf4%dQ%g@Z3zGpLZtyu$!F0PKOgaQT>5Qu1N zw?>3|te$UNxIM$&_?mLp*TsPmPWo&_;C3GTth*7nwRQb)VmH@@-qr*GHPeXwTL(?6|>J_8&9Ig$VwxAxfb>W5HaO=%vJcmtS!8l)2C5Kr=bMVws3gqpd% z(3B6Ryd1Y+!tbYx|4wrk+rp1+-vzr@yuItmRWJSK{ts9E-ve-j*x```NCxM}Ug~=F zqSNCSRIX>KO`npupvuCfNnj8-BJOBVh?U{4`?_-mA!j7WgaAM^g0fOzBl8$(YokzK zPoS;Sf;0!vV;n8YNeRDr%wdq5!=Shr*!T$+D=FiZWOL$@1X%HQ!%Hv!*9-r!{K*^k z!eM5IM+zVb&Mm*Hy6MKNCqCG+oppVOeg5E35> z-6DsGO>WA!DsfD+LZ-@i%s)?1c|kVPDW7=6;tVE?C3NOVK=aGN*~fu;`uXbnZu{=I zoAEQkA(kTnkPOC)mj1f@SHJx3-jN5H`=@n8^gAKp_`nbakfp6ioMrhyY3m{8PxqT9 zeYIUC4Z{dEe}ZiTsG{zA5yxC^c`31U`Jjy*&Dg1@5wCC(IIDNU{@>pB^yx1?eq|LL z26lKP01`&wJ@{<$<3&>vGdF(-w5c7eIFAPoQNSp0EalZhz>x6G%0&?cy)P3!X1{iI zqC5@CVxJ^En<{63qP{r@OJizrl~oDSLHYXFqnUfkY2ef+2t4vw)#7`8I_Y(O3ik_; zhC?Ja9DZR}|KkVqFFJSP&zkl?s`>ypnh*#;Y)lH2i|YoCvMkW$(E^d*J?%I3KoH%v zRWj93u$2n22;$)oY}o4L{k6o}^BJKN3#fPU{83li1y@zCd*}X-zz6A&u)Mb_0j$D|>H)X#3;poN~bDSipjLr zI&hO|&X8>7aWZIFW&%j0CHD|t7zvhv2zHrZMSerymWhvx)}7$4x{JHJ_t)65=Ix$) z#WyRS`u=U7%>ds+4q3m46CnBM^Uk^A2ha*oQ^#ABjxWk}$lhKCHzGO~l%X9IwO(d+ z1q%r;y$ORb17{FE2VYiOvW0(R@YFuhO%Q#ibsKnMs+xC*=S^CF+R}f^hC@KT4>ds8 zy|c5HEpv{UU2;Xt1KO4Zv?%)Q zs-R{Fytq!ZeFt`gh-ZUDfKq5adt+B<@*Qywv^t z8=stX`<+|w{^io!8%`PF7Sz^B(qn=yW>Oyht&}L)lv0H5lW0(>iVci~t$~>W;Y9++ zB8)DB8EN*08xP(N)4tHkY{xhh4Nb(_y%*Ax?bw2=zPI))@Y~h^9dTH(Llx0Nq@FLo z=?|x0e)%`=J8gD+cJT;+#(ls(_!D^5`@s=Jz)?w1%-ry{47~crB1FOJ;pVlHdTMFC zwLqhMY`Lg`NNdwpM1#g67SZC;)ZZ_*Q(`|S*U7Yh&7+P6+L{M@Z(K?yg6e9(AY>>2 zX{&M1zVhDEZ(nu&9~UponH%o{wre}{wrnAGT^%oD`*}7;kLM87DMe`=O&ZJ^1p`dJ zqBlS?9q8+>vGpjyombOYMc{bK5=FX5{}wKoDWF8QA?^9=F7D2YiongA2+lieH=O_E z{j*oVAZ4)rQO6qh=6~IJ{S7x9^GG4T`7LW9y`P;Ul zv=&m3utnrXptN|x`ToJS-300BXRebR!!w@lE^3JzQP=avKan25LbDoJU^i`efcrXZohqEVb?IH?k>y*A z03b?vZKWX3^4*tZ1-UK@F*{2Es4WUuxY-%E7TFigQW6Gz=jfEsXuGBXkf2?iT6MeK?Y@`XS7%I)&0F^#O>Ns@#T_x869bdB z!E1sB;G!C2O50K?+S*t*_8ZV-7Pv9&&H(R2Enf%pOs>zAjl>l_W<(K5&Eqhv>0_=>8d!XkGqSv3+dbR1ZR@8^hn3zRk!X@ODM#BEY}v@V{<70bh&ZEN znv5%GEr|gHz%Zq8YMJ5^6oSZtroHl%$fIBafkmGK_;|!8M$cjB92@|3BQSL%BB05D z%xbG^xHJI9MSy#lTzEjTT_{RA=Zpe)t{w&`{Q*eEoCTNMJ^sA;rT<>Lle(XLY9*|g zpDgSKj3NMxpxKF~5 zV=OolXR!4dkP!9Oq(MyyW88}D=BCE+yo$pDkc>MQU;D$CQ_T#v{Kc|_A5JK@258a4 z1RoyEe_h zh_1m3ubDbq>%&rJBLHk{q%i&HG)$YE;6PFM)5a7uwIrZmBryu>$x=dT8C4E%M~Mcu z8hHyegPTN_hZ&q)DL_nz6ak_Mg@@w~L-bn%&08>KFAZuayf1+0k}IE@Hg-b6@%6jG zO}WGotj$dFAi+WqKvF>#^7&Q(QbVH$f3=vx+!+a(BC+_1^XA0i(Pz8h^SxOxG8asT zP)HOQv#DJW1Xe~PF^ER23ip*Pr951hTxoO>P{w-HImW6GI3bJjPt%r1gqE|M>Vq%{ z=`)*McEX}tI(XHMP2dof?9?s{?67-dC>}B8`Y1Zq+6gdg8sp%^pmINh-L(L_ct4@8 zg}~L{jPr*|L6?dNdX@E9i``K7R))IYTIKGCZ_s59u0;= zG#DPBZFCNUfKgn`RV8^PvyKDk6voaOfk*|36n#^>n2Tn?#bLwaU67Tt^ge^BZ;28P{89 zYjV4K)EFE}fGoFsuIBhAg`+{h{U4iXqQHc{# z4UVd^XW_%tBcx(j0ElU|$nBWPgu3+rN^U~9MT>I^3vGCQ73EgR8wwHC~` zEeY1Pt(*XNu>60#`zMcqUvDG$3lzOq&v{t)V>* zt2U;ge6&B`+1wB>*vQ8o^K%?nG@I~kCLi7~K?}JVJJMkAHVKqUA#7zD4BT(i4S`w^ z8Xhrj+zzf`;;$vQ9uQ2T#cbtYmE+=zIel1OVdx98LgZ#RHmCZeo!gUV{R} z0HclxS0_6AjrGJUNRU%cS@`xe=!X^c3edjKKHlMEgP(@?f=(I+2^2^=KV|Y-6oCX` z5OwZMBuC%gXh8#KY=wE<3Bwnhb}u*~2BrC~d_PAvY7Ghm%EobnVQV5`%aV#jV_0}0 zb^xk&XSlCtSz&7=c4S-CZEIlEG&2p71dM~!)B;XzHSd(4bK>plQ3(tdpvLCL^&%A` zYfzm68U;xKffI)`ay(>33_{~A@kAV+S?NJhUfA-4VqBSCv0&*U0tZ{Di9K4GSO_8o z*iu5w3y}lB0O$o-4{rU~tq~2f0>DzrH#th%q;~?K9RIhN8z^3uZfgdwprmNYi6{PT z4n(5FfB>r9v3gxj9*>I-_du%KZN@#sw)#!gTIyU`lT5{X5PAVB1egLVmyeQcsI+0r z?i7?3gkzC-sOkoQ>62nmBCH6&WCnmlKtNVQP+Y-mPlIQUYb(Gp$O!`jNYMlxxAeAy z0E_|?`AyUyXDSUO*$P>uCtN%a`jFlLA{*ZS<^GmNTFLz<5i=;|7`x~TD-fi`U?AK8 z5Z!mc!@<}U8c@Z#TnpQFQ5cbkSP}RZX>&S0GRKwI3Rz%8Or`+>g)<$*Ewm3%%(4Q@ z&@V3IeIcgjgsn&eCEOZzZF-UAm38U0h(&1+tQOwz3QFfLfgu1SpL6%leSgTykuN?| zPk}^LvdYY4(LiC4+yDfcX;I}x;4Fxz;F1M#sBM7I3{e4aE~lvNNd{lkCgity1}X%? zu?WW_v(F9)0YTsxr4k5FSH@PRv-{ZPA>gEiIe{hF>bf1g`%qLe=JdYq-|Fl6DmVP` z{_Gr}i5L@;AAD<2pIc#g@%(8@i?C0T|l#>6ZhnsH>Drmbdt> zSQ#vR)*cc->sNlS;Xp&z%Ogg}_QKpWvK(Yl6b+&R=y~({BW{wgXHalY%+N z#i5!HH#q2SMER0@k^1rA^$!RXDPFogt$Yu;xlDlZaKrd9Cs zL<(s0=PB5@-GSU}2qqEIAjSc5pFqR(-H7f`Gsrxl9)YBXAcWIR*u z&5G>DGj0Vl=D<1~G11c;8bIQ{tp|Se;CJJrNJ~M1XK|&_w6YkXbYWGpg{On~N24dRb+FchCaxlTaG`F@KxcF*#xUT6CZVDM-e{pgK_vlm>lU`MLm zbq_W>mWxeY!ia8M-I#V69%@nh4p6x65}ptpVM~C-qzX|~=vjbQQ3mkp>J;p)k3(6p z53F%N5DxfEwP=&!RxvN->YYrTi)e$`XYxvrp)2nr!_%}7bBLTbp}{g>+d+TMOn6lH zB4)$t8MShbpL%xlX&PeUk{g#Z1YyVkiK=7I|K`k{1?AP>o-(!U$+iutR7Wx%w{$;) zL|PL?3RPfG)e065RKPo~?d={s@ftw!D9d+=meHlLd0nqX z18{^iG&9K0^%Qo5PF zN`l`qQB1+S)vn`|jBAekv`lMuC+Rp800|LY)Y$L-cllF)ee?U1N0-jOz4Z$aA4nz! z3`3Pr0@L#0l^BU(?P?e9yv63ZHA}X1+tw=cU1CWIz`sA)#aUdobXziq8nqa89Ub(E zuUlxSqj3HyDY)tzao#$0gdVCeShqb5zkS)0<5-Z1>g(r5An9q$x(|lmxfN^M7E1AI zR;^m*_ICAwK7srO&7R%yH*!b8?L3|ks;$L>9zXaX)}mY|Z!2?-cVYJQ1XS+#V~-XQ zU&=-S?A@P&|CBCu5J1N`jGyRgl%TrQ~&GF#fS4`_!IJ1EVHZe$=P-JB@%Wds?fBnh_8~Vav zz!p?dQISZeAUB=#-{dxL0a%E!>1||1qLq*2NsT|Quwc&2PB`a`Z25f~ks7i26aW0q zB($|>!-x?+FlOR~8mKb^XzCgya*cd`-gB=4%$}NpsgvSzdNDB$21jr$nL0;4wGN69 z`R;9JiorwAdYm0PD*LXinFY0)S5y!;PqXEj| z#ZQc^IOE&3+txxtemO+B9n4Yy1@ug*ll?U=EMAy|Gv;PP2ao2@z2d^2{Ue}c43Q7g zE#+dVsz|s_oJSKbPqg^v<}_S(t_RbP&EnSG{n>pw9_|O3H3|O|{j&oNF1+|I1D9K< zh>-?9gg4G!m=6P@%m<@^^^YD6G1!%w)w5PL@PT;Osv}~CNv-}$h zS&SD}vYC>llsTr@fp^yC!YeBo_w8{gDoj8TcOz~xVs#n}05i?WfQ62UZBHG`&yT_L zZ#wYQt0Kr-23Q>@@t1{#=y(;fNY&#P_ib~OMsenr?F6%r2zu^rnOW`PB9s?vStI0g(**WM@67P&3Sn3W|zXKW#50%T4&YK1D`M3Ch-3whbZC_0r6 zm$#5R)15ri|G`Qnfd%QTbN&dvk%JlqAX0&?J!;{006+j3@iXSBpf1I}0~(J&!XwN2 zQ=7taEJA5PA&IxRY{Scso%b$4|GwWdV1OzsD^r(dS*_fN@(CZ*kS{{XCLXkO-4cyb z!Dlbs`ZQXp5w|E)Lo_IBVXH)-P^54#+9=RM?I3QLOozH>X$bm0U1XUg46tcr0HMAO zsOQk&2%#7xf9Qpk6j54U;aJZ<`mblJGbr>!21B!1Q+@5$L{`c8&X!=Oc4fM2Z(6-t z8~;uaSOh|@k)gG5LsV61p>rW@In;_MRB!7to?oXa3t;kQ0z3(RhWewF9+2w}9vqAX z5uMsmM8Q+@Hj46Ddi*pezH)ul!ndCLU(L`@>k%@jeo%d~<-NR|l5cc2`7be95rr^h zHGn|V4}>Rpgj@`C-xn>1U0u{7vDN4)fzrC7$1xjJNnSwp7?3rXB(NIui~!-!dMZ~Y zL{oL)1&Z>#^n|H)e0NprPyX|^NvmLhUj-h-C_m36Oq|b{Vjd`XBxseRqs z*7vUW1BBiImAiCM1w>I*KSY*S#|aCzLAj1hlPAr4r(U>UqOaF&N|*}helpNs>#-*t zL-0Lyo4iRQ@rL@ABiv>2D4IIzcsss#U-Mme-e8c5bmF^qH~;Len+kpf!G{kAB!jUaQnziDH}i~J zrgk+aW_Gm7z8x_=Y(=^Crz-)qYKCYjo`@f{He+G9EbFB;v&eaoUOi>q%bIi4tMob} zpnq%Yz^_8$_bJW5p-%$*>qpo9gqyl#RGLC6HIN^=RT%gq6AT)7LArt{uc%-y)+56?{h&xg+10z>=~J%PcA3zI;HJQ;^M~HrjI9joZquQ-N*p(4V}hjg!)6#|K(V|qU-4+s=skdfixDayD%mY>fEjkhje@!Vzq4^*}Y4wDRKzz&Oe zyx1#`9=o==v+3qpi=0?)wwuxu=%crd={9cN-8znlF(E<~yg;UQ5c9=}pdS6TDLtV- z^!5eX)}O!B`((#gO|aB+$|;)9C#NurkCyY1hp|T!*tz}X|ATaJ*kmX%2#chWH~n+X zUD>SUmXF_Y(~T`WPfyVBGY;mx-4MxqhdsryXpGX=F>l(TGToRWfsAa>oTKBSB0FL= zx`eTbCz4%v|BxID{TR;~stnbFw8$@LJak*>tx0IS{j`gmSZT56I-(JJ;g}Jc{Epn5 zmTnzM)HlJY9@7a`KlO)@^xG5K!YnUvV}NpG=6xkEn1EtlI`e|GjkYdPhSTa)#dJdc zvVQ-y`gY+k%b~I$Et1)N`)6M)o;q>dYui>c*i*&a)^^L$G?kD(?uPVyQ$3wax>3U5 zC*E!PGX#Q9ZAPvaWpzRX3Sz`!{3OpBJCl8Q_dmquz!CbF(?cZ*QIRp}llSl09!rh+ z_4Kn(SyEO`O58Tjb5j;er+I_c!8_`Hv|#pxq*rhv#3MvT0K%V?L5z+l@*(?Ps60ia zLw-_>TTtPsw0F|Ej`ijX4<27#xjY3&zGaGqV=>QZz4e}- zjJz8T@qc;jOAaSM_!{P!7hH7TvEwVw`&Loe#7m3vv!|q!kWbTmB*0ZYU+`m<00^=n z*_BLh^}y>&HfEi^WsRF^ZFAz3^e{QGj~J$lKnA9W_fs{%Sja7B)&e`$VYU3r13%6G zIUJ$56S=h`el6p>r%hWLFP?Bl$%y=d@>DuGj(Ki2v202#r{2yQz59!g->BZY^&$CR zL$3R2?LU{46#x9sE0|kbM=V!N2}a^mRWxaQ4`vOW&$oPhsyaNVj!ZF;RhK_YH!nEW-4JX!^NW}zI-rSBtY+(` z)z>`#@PapVG58YPTJ&DOZW2=5mEYgAq?qq}0>Au6R4lImNPa0mK^ZWcxF3DE{{1^%{r#d>)inj;pR5IdYT@6NKL zj-PSi=rMCHC@vXw5=}v&%^-^kWs5lzKCB)uon&!V-_+ds=a!nyuU1vQ^WyuxcRviElqpZwyj-zPZE{aWTn5P zWmtwN{cUvr9UAAb49l<#%diZ~unfzvd?n@o0~ \ No newline at end of file diff --git a/src/frontend/web/von_interface/static/images/providers/gemini.svg b/src/frontend/web/von_interface/static/images/providers/gemini.svg new file mode 100644 index 00000000..38f56259 --- /dev/null +++ b/src/frontend/web/von_interface/static/images/providers/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 729dfd59..ddbbcc6d 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -10,6 +10,7 @@ import { createChatSteeringControls } from './components/chatSteeringControls.js import { directConversationRows, activeMessageConversationId, showChatConversation, resetConversationCatalogue, renderMessageConversationRow, mountCatalogueControls, initialiseConversationCatalogue, selectMessageConversation, filterCatalogueRows, catalogueSearchActive, rankCatalogueSearchRows } from './components/conversationCatalogue.js'; import { catalogueHasMore } from './components/conversationCatalogue.js'; import { profileButton, participantAvatar, participantIdentityAvatar } from './components/participantProfile.js'; +import { importedConversationAvatar } from './components/importedConversationAvatar.js'; import { setButtonLabel } from './utils/buttonLabel.js'; import { createConversationTray } from './components/conversationTray.js'; import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; @@ -27535,7 +27536,9 @@ function renderChatSessionTabs(sessions, activeSessionId) { const header = document.createElement('span'); header.className = 'chat-session-tab-header'; - header.append(participantAvatar({ display_name: session.external_conversation ? displayName : 'Von', avatar_url: session.external_conversation ? null : '/static/VonImageBig.png' })); + header.append(session.external_conversation + ? importedConversationAvatar(session.external_conversation.provider, displayName, { sidebar: true }) + : participantAvatar({ display_name: 'Von', avatar_url: '/static/VonImageBig.png' })); const pinToggle = document.createElement('button'); pinToggle.type = 'button'; @@ -38369,10 +38372,7 @@ function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory = if (options.contributionTimestamp || timestampStr) messageContainer.dataset.contributionTimestamp = options.contributionTimestamp || timestampStr; let assistantAvatar; if (externalActor) { - assistantAvatar = document.createElement('span'); - assistantAvatar.className = 'chat-imported-actor-avatar'; - assistantAvatar.textContent = String(sender || 'Agent').trim().slice(0, 1).toUpperCase() || 'A'; - assistantAvatar.setAttribute('aria-label', `${sender} (imported actor)`); + assistantAvatar = importedConversationAvatar(externalActor.source_actor_id, sender); } else { assistantAvatar = document.createElement('img'); assistantAvatar.src = '/static/VonImageBig.png'; diff --git a/src/frontend/web/von_interface/static/js/components/importedConversationAvatar.js b/src/frontend/web/von_interface/static/js/components/importedConversationAvatar.js new file mode 100644 index 00000000..302f5abf --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/importedConversationAvatar.js @@ -0,0 +1,27 @@ +const PROVIDERS = { + codex: { label: 'Codex', file: 'codex.png' }, + claude_code: { label: 'Claude Code', file: 'claude.svg' }, + copilot: { label: 'GitHub Copilot', file: 'copilot.svg' }, + gemini: { label: 'Gemini', file: 'gemini.svg' } +}; + +// Provider branding describes the imported source, not a verified Von identity. +export function importedConversationAvatar(providerOrActor, displayName, { sidebar = false } = {}) { + const provider = String(providerOrActor || '').split(':')[0]; + const brand = Object.hasOwn(PROVIDERS, provider) ? PROVIDERS[provider] : null; + const avatar = document.createElement('span'); + avatar.className = sidebar ? 'participant-avatar imported-provider-avatar' : 'chat-imported-actor-avatar imported-provider-avatar'; + avatar.setAttribute('role', 'img'); + avatar.setAttribute('aria-label', `${brand?.label || displayName || 'Agent'} (imported)`); + avatar.title = `${brand?.label || displayName || 'Agent'} ยท Imported conversation`; + avatar.textContent = String(displayName || brand?.label || 'Agent').trim().slice(0, 1).toUpperCase(); + if (brand) { + const image = document.createElement('img'); + image.src = `/static/images/providers/${brand.file}`; + image.alt = ''; + image.addEventListener('load', () => { avatar.classList.add('has-provider-logo'); }); + image.addEventListener('error', () => { image.remove(); avatar.classList.remove('has-provider-logo'); }); + avatar.append(image); + } + return avatar; +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 38260757..0b252ee9 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -8415,6 +8415,27 @@ body[data-active-tab="settingsTab"] .tab-content-area { font-weight: 700; } +.imported-provider-avatar { + position: relative; + overflow: hidden; +} + +.imported-provider-avatar.has-provider-logo { + color: transparent; + background: #fff; +} + +.imported-provider-avatar img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 4px; + box-sizing: border-box; + object-fit: contain; + background: #fff; +} + .external-conversation-bulk-import { display: flex; align-items: center; diff --git a/tests/backend/test_external_conversation_timestamp_fallback.py b/tests/backend/test_external_conversation_timestamp_fallback.py new file mode 100644 index 00000000..65b53b6e --- /dev/null +++ b/tests/backend/test_external_conversation_timestamp_fallback.py @@ -0,0 +1,197 @@ +from datetime import datetime, timezone +import json +import os + +import mongomock + +from src.backend.services import chat_history_service as history_service +from src.backend.services import external_conversation_import_service as importer + + +def test_file_mtime_fills_only_missing_message_dates(monkeypatch, tmp_path): + path = tmp_path / "source.jsonl" + path.write_text( + "\n".join( + json.dumps(record) + for record in [ + {"type": "session_meta", "payload": {"id": "dated-source"}}, + { + "type": "response_item", + "timestamp": "2026-01-02T03:04:05Z", + "payload": {"type": "message", "role": "user", "content": "Dated"}, + }, + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": "Undated", + }, + }, + ] + ) + ) + modified = datetime(2026, 2, 3, 4, 5, 6, tzinfo=timezone.utc) + os.utime(path, (modified.timestamp(), modified.timestamp())) + original = importer._history_projection + captured = [] + + def project(package, **kwargs): + result = original(package, **kwargs) + captured.extend(result[0]) + return result + + monkeypatch.setattr(importer, "_history_projection", project) + monkeypatch.setattr( + history_service, + "classify_external_conversation_projection", + lambda **kwargs: "new", + ) + importer.import_external_conversation_file( + path=path, + custodian_user_id="#V#user", + namespace=None, + organisation_concept_id=None, + role_in_org=None, + dry_run=True, + ) + assert captured[0]["timestamp"] == "2026-01-02T03:04:05+00:00" + assert captured[0]["external_timestamp_source"] == "source_message" + assert captured[1]["timestamp"] == modified + assert captured[1]["external_source_timestamp_utc"] is None + assert captured[1]["external_timestamp_source"] == "source_file_modified" + + +def test_reimport_repairs_fallbacks_preserving_source_dates_and_appends(monkeypatch): + collection = mongomock.MongoClient().test.chat_history + monkeypatch.setattr( + history_service, + "get_chat_history_collection_service", + lambda **kwargs: collection, + ) + monkeypatch.setattr( + history_service, + "_read_find_one", + lambda coll, query, projection=None, **kwargs: coll.find_one(query, projection), + ) + manifest = dict( + schema_version="external_conversation_import.v1", + read_only=True, + custodian_user_id="#V#user", + source_identity_key="same", + source_sha256="a" * 64, + package_sha256="b" * 64, + parser_version="old", + provider="codex", + ) + dated = dict( + role="user", + content="Dated", + timestamp="2026-01-02T00:00:00Z", + external_source_timestamp_utc="2026-01-02T00:00:00Z", + external_event_id="one", + ) + undated = dict( + role="assistant", + content="Undated", + timestamp="2026-09-16T00:00:00Z", + external_source_timestamp_utc=None, + external_event_id="two", + ) + + def write(entries, revision): + return history_service.upsert_external_conversation_projection( + user_id="#V#user", + session_id="external", + session_name="Imported", + namespace=None, + organisation_concept_id=None, + role_in_org=None, + history=entries, + manifest={**manifest, "parser_version": revision}, + ) + + write([dated, undated], "old") + repaired = { + **undated, + "timestamp": "2026-01-03T00:00:00Z", + "external_timestamp_source": "source_file_modified", + } + extra = {**dated, "content": "Appended", "external_event_id": "three"} + result = write([dated, repaired, extra], "new") + assert result["corrected_fallback_timestamp_count"] == 1 + assert result["appended_message_count"] == 1 + stored = collection.find_one({}) + assert [e["timestamp"] for e in stored["history"]] == [ + dated["timestamp"], + repaired["timestamp"], + extra["timestamp"], + ] + assert stored["updated_at"] == datetime(2026, 1, 3) + assert write([dated, repaired, extra], "new")["action"] == "unchanged" + # Changed historical content remains a divergence, never a timestamp repair. + divergent = write( + [ + dated, + {**repaired, "content": "Changed", "timestamp": "2026-03-03T00:00:00Z"}, + extra, + ], + "newer", + ) + assert divergent["action"] == "diverged" + assert divergent["corrected_fallback_timestamp_count"] == 0 + assert collection.find_one({})["history"] == stored["history"] + + +def test_repair_corrects_conversation_creation_date(monkeypatch): + collection = mongomock.MongoClient().test.chat_history + monkeypatch.setattr( + history_service, + "get_chat_history_collection_service", + lambda **kwargs: collection, + ) + monkeypatch.setattr( + history_service, + "_read_find_one", + lambda coll, query, projection=None, **kwargs: coll.find_one(query, projection), + ) + kwargs = dict( + user_id="#V#user", + session_id="external", + session_name="Imported", + namespace=None, + organisation_concept_id=None, + role_in_org=None, + ) + manifest = dict( + schema_version="external_conversation_import.v1", + read_only=True, + custodian_user_id="#V#user", + source_identity_key="same", + source_sha256="a" * 64, + package_sha256="b" * 64, + parser_version="old", + provider="codex", + ) + entry = dict( + role="user", + content="Undated", + timestamp="2026-09-16T00:00:00Z", + external_source_timestamp_utc=None, + external_event_id="one", + ) + history_service.upsert_external_conversation_projection( + **kwargs, history=[entry], manifest=manifest + ) + history_service.upsert_external_conversation_projection( + **kwargs, + history=[ + { + **entry, + "timestamp": "2026-01-03T00:00:00Z", + "external_timestamp_source": "source_file_modified", + } + ], + manifest={**manifest, "parser_version": "new"}, + ) + assert collection.find_one({})["created_at"] == datetime(2026, 1, 3) diff --git a/tests/browser/importedConversationAvatars.cjs b/tests/browser/importedConversationAvatars.cjs new file mode 100644 index 00000000..d51ad158 --- /dev/null +++ b/tests/browser/importedConversationAvatars.cjs @@ -0,0 +1,45 @@ +// Exercise the production avatar module, styles and real local brand assets. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2]; +const server = http.createServer((req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html'); + return res.end('

Imported conversations

'); + } + const file = path.resolve(root, `.${req.url}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end(); + const type = { '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png' }[path.extname(file)]; + res.setHeader('Content-Type', type || 'text/plain'); + res.end(fs.readFileSync(file)); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 760, height: 600 } }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async () => { + const { importedConversationAvatar: avatar } = await import('/static/js/components/importedConversationAvatar.js'); + for (const [provider, label] of [['codex', 'Codex'], ['claude_code', 'Claude Code'], ['copilot', 'GitHub Copilot'], ['gemini', 'Gemini'], ['unknown', 'Other agent']]) { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:16px;margin:20px 0'; + row.append(avatar(provider, label, { sidebar: true }), avatar(`${provider}:agent`, label), document.createTextNode(label)); + document.querySelector('main').append(row); + } + }); + await page.waitForFunction(() => [...document.images].every(image => image.complete && image.naturalWidth > 0)); + assert.equal(await page.locator('.has-provider-logo').count(), 8); + assert.equal(await page.locator('[aria-label="Other agent (imported)"] img').count(), 0); + assert.equal(await page.locator('.participant-avatar').first().evaluate(el => el.getBoundingClientRect().width), 32); + assert.equal(await page.locator('.chat-imported-actor-avatar').first().evaluate(el => el.getBoundingClientRect().width), 40); + if (evidence) { fs.mkdirSync(evidence, { recursive: true }); await page.screenshot({ path: path.join(evidence, 'imported-provider-logos.png') }); } + await page.locator('img').first().evaluate(image => image.dispatchEvent(new Event('error'))); + assert.equal(await page.locator('.participant-avatar').first().innerText(), 'C'); + console.log('Provider logos loaded at sidebar/message sizes; unknown and failed-image fallbacks verified.'); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });