core, ui: terminal chat layout for LLM and service bot chats (#7811)

* plan: UI improvements to improve LLM usage via SimpleX

* core, ui: types for chat layout

* core, ui: settings for alternative terminal-like chat layout for managing LLMs or other service bots

* plan: message priority

* fixes

* query plan

* update plans

* remove silent messages plan

* rename migrations

* implement layout

* amend

* fix

* fix

* fix

* improve

* time on the right, sender name in blue

* no avatar in direct chats

* better previews

* update, fixes

* update

* time gap

* changes

* ui: terminal layout line heights, gaps and time placement

* ui: simplify terminal meta layout, correct its docs and tests

* ui: right-to-left emoji edge in terminal rows, time placement tests

* android, desktop: raise Kotlin compiler memory to 4 GB

* ui: terminal captions as follow-up text, classic event separators

* ui: terminal media edge and overlay time, plan decisions

* ui: simplify terminal row separation, correct doc line references

* ui: keep the media time inside terminal rows, restore caption conditions

* ui: terminal heading capitals at large font scales, idle live message time

* ui: rename the classic voice-with-text flag, correct the meta copy docs

* ui: smaller terminal layout diff, sender gap and meta names

* ui: terminal row as tall as the media time

* docs: remove the chat layout section from the bots readme

* docs: shorten the terminal layout section of the chat view

* core: address settings API as on master, /auto_accept reads the stored layout

* ui: smaller terminal headings

* ui: classic sender rows and avatar rule as on master

* ui: classic reply quote text colour as on master

* ios: keep the chat layout setting with other UI settings

* ui: go-to button at the end of terminal rows, no gutter in direct chats

* ui: forwarded arrow on the header line of terminal rows, above the time

* ui: forwarded arrow centred above the time line of terminal rows

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Evgeny @ SimpleX ChatandEvgeny Poberezkin authored and GitHub committed 2026-10-25 18:03:11 +00:00
1 parent 4f81c6f632
commit 7810fc4752
103 files changed
+5098 -901

No files matched your search

+2 -1
View File
@@ -123,7 +123,8 @@ badgeGroupTests = describe "badge group" $ do
image = Nothing,
publicGroup = Nothing,
groupPreferences = Nothing,
memberAdmission = Nothing
memberAdmission = Nothing,
chatLayout = Nothing
}
it "says nothing when the config matches the group profile" $ do
inertGroupConfig (cfg "SimpleX Badges" (Just "badge ops desk")) (profile "SimpleX Badges" (Just "badge ops desk"))
+28
View File
@@ -43,6 +43,7 @@ import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNe
import Simplex.Chat.Types
import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation)
import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..))
import Simplex.Chat.Types.UITheme (ChatLayout (..))
import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMessages, vrValue)
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.RetryInterval
@@ -311,6 +312,7 @@ chatGroupTests = do
it "should update channel profile (signed)" testChannelUpdateProfileSigned
it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate
it "should preserve working link after welcome message update" testChannelLinkAfterWelcomeUpdate
it "should set chat layout of channel joined via relays from link data" testChannelChatLayout
it "should preserve owner key in link data after profile update" testChannelOwnerKeyAfterLinkUpdate
it "should update channel preferences (signed)" testChannelUpdatePrefsSigned
it "should change member role (signed)" testChannelChangeRoleSigned
@@ -10552,6 +10554,32 @@ testChannelLinkAfterWelcomeUpdate ps =
bob <# "#team> hi"
[cath, dan] *<# "#team> hi [>>]"
testChannelChatLayout :: HasCallStack => TestParams -> IO ()
testChannelChatLayout ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
(shortLink, _fullLink) <- prepareChannel1Relay "team" alice bob
alice ##> "/set layout #team terminal"
alice <## "chat layout changed to: terminal"
bob <## "alice updated group #team: (signed)"
bob <## "chat layout changed to: terminal"
cath ##> ("/c " <> shortLink)
cath <## "#team: connection started"
concurrentlyN_
[ cath
<### [ "#team: joining the group (connecting to relay bob)...",
"#team: you joined the group (connected to relay bob)"
],
do
bob <## "cath (Catherine): accepting request to join group #team..."
bob <## "#team: cath joined the group",
alice <### [EndsWith "introduced cath (Catherine) in the channel"]
]
groupChatLayout alice "team" `shouldReturn` Just CLTerminal
groupChatLayout bob "team" `shouldReturn` Just CLTerminal
groupChatLayout cath "team" `shouldReturn` Just CLTerminal
testChannelOwnerKeyAfterLinkUpdate :: HasCallStack => TestParams -> IO ()
testChannelOwnerKeyAfterLinkUpdate ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
+166 -3
View File
@@ -166,6 +166,12 @@ chatProfileTests = do
it "files & media" testGroupPrefsFilesForRole
it "SimpleX links" testGroupPrefsSimplexLinksForRole
it "set user, contact and group UI theme" testSetUITheme
describe "chat layout" $ do
it "group chat layout from group profile" testGroupChatLayout
it "group chat layout from group link without prepare" testGroupLinkChatLayout
it "unknown chat layout stored and read" testUnknownChatLayout
it "contact chat layout from address" testAddressChatLayout
it "business chat layout from business address" testBusinessAddressChatLayout
describe "short links" $ do
it "should connect via one-time invitation" testShortLinkInvitation
it "should plan and connect via one-time invitation" testPlanShortLinkInvitation
@@ -1125,7 +1131,7 @@ testUserBadgeLinkDataOtherLink ps = do
alice ##> "/_connect 1"
(shortLink, _) <- getInvitations alice
Right otherProof <- badgeProof pk cred (PHLink "other link key")
setTestInvLinkData alice $ ContactShortLinkData aliceProfile {badge = Just otherProof} Nothing False Nothing
setTestInvLinkData alice $ ContactShortLinkData aliceProfile {badge = Just otherProof} Nothing False Nothing Nothing
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "invitation link: ok to connect"
sLinkData <- getTermLine bob
@@ -1139,7 +1145,7 @@ testUserBadgeLinkDataNoProof =
alice ##> "/_connect 1"
(shortLink, _) <- getInvitations alice
let shownBadge = ShownBadge BadgeInfo {badgeType = BTSupporter, badgeExpiry = futureDate, badgeExtra = ""} BSActive
setTestInvLinkData alice $ ContactShortLinkData aliceProfile Nothing False (Just shownBadge)
setTestInvLinkData alice $ ContactShortLinkData aliceProfile Nothing False Nothing (Just shownBadge)
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "invitation link: ok to connect"
sLinkData <- getTermLine bob
@@ -3835,7 +3841,7 @@ testSetUITheme =
alice ##> "/i #team"
groupInfo alice
where
theme cm = T.unpack $ encodeJSON UIThemeEntityOverrides {light = Nothing, dark = Just $ UIThemeEntityOverride cm Nothing defaultUIColors}
theme cm = T.unpack $ encodeJSON UIThemeEntityOverrides {light = Nothing, dark = Just $ UIThemeEntityOverride cm Nothing defaultUIColors, chatLayout = Just CLTerminal}
userInfo a name = do
a <## ("user profile: " <> name)
a <## "use /p <name> [<bio>] to change it"
@@ -3851,6 +3857,163 @@ testSetUITheme =
a <## "group ID: 1"
a <## "current members: 1"
testGroupChatLayout :: HasCallStack => TestParams -> IO ()
testGroupChatLayout =
testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
connectUsers alice cath
createGroupWithLayout alice "terminal"
groupChatLayout alice "team" `shouldReturn` Just CLTerminal
addMember "team" alice cath GRAdmin
cath ##> "/j team"
concurrently_
(alice <## "#team: cath joined the group")
(cath <## "#team: you joined the group")
groupChatLayout cath "team" `shouldReturn` Just CLTerminal
alice ##> "/create link #team"
(shortLink, fullLink) <- getGroupLinks alice "team" GRMember True
alice ##> "/set layout #team classic"
alice <## "chat layout changed to: classic"
cath <## "alice updated group #team: (signed)"
cath <## "chat layout changed to: classic"
groupChatLayout alice "team" `shouldReturn` Just CLClassic
groupChatLayout cath "team" `shouldReturn` Just CLClassic
alice ##> "/group_profile team"
alice <## "#team"
alice <## "chat layout: classic"
alice <## "group preferences:"
mapM_ (\((_, feature), _, _) -> alice <## feature) $ groupFeatures_ 0 False
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "group link: ok to connect directly"
groupSLinkData <- getTermLine bob
bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData)
bob <## "#team: group is prepared"
groupChatLayout bob "team" `shouldReturn` Just CLClassic
alice ##> "/set layout #team default"
alice <## "chat layout removed"
cath <## "alice updated group #team: (signed)"
cath <## "chat layout removed"
groupChatLayout alice "team" `shouldReturn` Nothing
groupChatLayout cath "team" `shouldReturn` Nothing
bob ##> "/_connect group #1"
bob <## "#team: connection started"
alice <## "bob (Bob): accepting request to join group #team..."
concurrentlyN_
[ alice <## "#team: bob joined the group",
do
bob <## "#team: joining the group..."
bob <## "#team: you joined the group"
bob <## "#team: member cath (Catherine) is connected",
do
cath <## "#team: alice added bob (Bob) to the group (connecting...)"
cath <## "#team: new member bob is connected"
]
groupChatLayout bob "team" `shouldReturn` Nothing
createGroupWithLayout :: HasCallStack => TestCC -> String -> IO ()
createGroupWithLayout cc layout = do
cc ##> ("/_group 1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}, \"chatLayout\": \"" <> layout <> "\"}")
cc <## "group #team is created"
cc <## "to add members use /a team <name> or /create link #team"
testGroupLinkChatLayout :: HasCallStack => TestParams -> IO ()
testGroupLinkChatLayout =
testChat2 aliceProfile bobProfile $ \alice bob -> do
createGroupWithLayout alice "terminal"
alice ##> "/create link #team"
gLink <- getGroupLink alice "team" GRMember True
bob ##> ("/c " <> gLink)
bob <## "connection request sent!"
alice <## "bob (Bob): accepting request to join group #team..."
concurrentlyN_
[ alice <## "#team: bob joined the group",
do
bob <## "#team: joining the group..."
bob <## "#team: you joined the group"
]
groupChatLayout bob "team" `shouldReturn` Just CLTerminal
testUnknownChatLayout :: HasCallStack => TestParams -> IO ()
testUnknownChatLayout =
testChat aliceProfile $ \alice -> do
createGroupWithLayout alice "wide"
groupChatLayout alice "team" `shouldReturn` Just (CLUnknown "wide")
testAddressChatLayout :: HasCallStack => TestParams -> IO ()
testAddressChatLayout =
testChat2 aliceProfile bobProfile $ \alice bob -> do
alice ##> "/ad"
(shortLink, fullLink) <- getContactLinks alice True
alice ##> "/_address_settings 1 {\"businessAddress\": false, \"autoAccept\": {\"acceptIncognito\": false}, \"chatLayout\": \"terminal\"}"
alice <## "auto_accept on"
alice <## "chat layout: terminal"
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "contact address: ok to connect"
contactSLinkData <- getTermLine bob
bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData)
bob <## "alice: contact is prepared"
contactChatLayout bob "alice" `shouldReturn` Just CLTerminal
bob ##> "/_connect contact @2 text hello"
bob
<### [ "alice: connection started",
WithTime "@alice hello"
]
alice <# "bob> hello"
alice <## "bob (Bob): accepting contact request..."
alice <## "bob (Bob): you can send messages to contact"
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "bob (Bob): contact is connected")
contactChatLayout bob "alice" `shouldReturn` Just CLTerminal
contactChatLayout alice "bob" `shouldReturn` Nothing
alice ##> "/auto_accept on layout=classic"
alice <## "auto_accept on"
alice <## "chat layout: classic"
alice ##> "/auto_accept off"
alice <## "auto_accept off"
alice <## "chat layout: classic"
alice ##> "/auto_accept on business"
alice <## "auto_accept on, business"
alice <## "chat layout: classic"
alice ##> "/auto_accept off layout=default"
alice <## "auto_accept off"
alice ##> "/_address_settings 1 {\"businessAddress\": false, \"chatLayout\": \"terminal\"}"
alice <## "auto_accept off"
alice <## "chat layout: terminal"
alice ##> "/_address_settings 1 {\"businessAddress\": false}"
alice <## "auto_accept off"
testBusinessAddressChatLayout :: HasCallStack => TestParams -> IO ()
testBusinessAddressChatLayout =
testChat3 businessProfile aliceProfile bobProfile $ \biz alice bob -> do
biz ##> "/ad"
(shortLink, fullLink) <- getContactLinks biz True
biz ##> "/auto_accept on business layout=terminal"
biz <## "auto_accept on, business"
biz <## "chat layout: terminal"
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "business address: ok to connect"
contactSLinkData <- getTermLine bob
bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData)
bob <## "#biz: group is prepared"
groupChatLayout bob "biz" `shouldReturn` Just CLTerminal
bob ##> "/_connect group #1"
bob <## "#biz: connection started"
biz <## "#bob (Bob): accepting business address request..."
bob <## "#biz: joining the group..."
concurrently_
(biz <## "#bob: bob_1 joined the group")
(bob <## "#biz: you joined the group")
groupChatLayout bob "biz" `shouldReturn` Just CLTerminal
groupChatLayout biz "bob" `shouldReturn` Nothing
alice ##> ("/c " <> fullLink)
alice <## "connection request sent!"
biz <## "#alice (Alice): accepting business address request..."
alice <## "#biz: joining the group..."
concurrently_
(biz <## "#alice: alice_1 joined the group")
(alice <## "#biz: you joined the group")
groupChatLayout alice "biz" `shouldReturn` Just CLTerminal
testShortLinkInvitation :: HasCallStack => TestParams -> IO ()
testShortLinkInvitation =
testChatOpts2 testOptsNoFullLinks aliceProfile bobProfile $ \alice bob -> do
+16
View File
@@ -33,9 +33,11 @@ import Simplex.Chat.Store.Direct (getContact, getContactIdByName, getContactRequ
import Simplex.Chat.Store.Groups (getGroupIdByName, getGroupMember, getGroupMemberIdByName)
import Simplex.Chat.Store.NoteFolders (createNoteFolder)
import Simplex.Chat.Store.Profiles (getUserContactProfiles)
import Simplex.Chat.Store.Shared (getGroupInfo)
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme (ChatLayout)
import Simplex.FileTransfer.Description (FileSize (..))
import Simplex.Messaging.Agent.Client (agentClientStore)
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction)
@@ -729,6 +731,20 @@ getTestCCContact cc contactId = do
withCCUser cc $ \user ->
runExceptT (getContact db (storeCxt chatController) user contactId) >>= either (fail . show) pure
contactChatLayout :: TestCC -> ContactName -> IO (Maybe ChatLayout)
contactChatLayout cc cName =
withCCTransaction cc $ \db ->
withCCUser cc $ \user ->
runExceptT (getContact db (storeCxt $ chatController cc) user =<< getContactIdByName db user cName)
>>= either (fail . show) (\Contact {chatLayout} -> pure chatLayout)
groupChatLayout :: TestCC -> GroupName -> IO (Maybe ChatLayout)
groupChatLayout cc gName =
withCCTransaction cc $ \db ->
withCCUser cc $ \user ->
runExceptT (getGroupInfo db (storeCxt $ chatController cc) user =<< getGroupIdByName db user gName)
>>= either (fail . show) (\GroupInfo {groupProfile = GroupProfile {chatLayout}} -> pure chatLayout)
contactBadgeHeader :: TestCC -> ContactName -> IO (Maybe (String, BadgeStatus))
contactBadgeHeader cc cName =
withCCTransaction cc $ \db ->
+8 -1
View File
@@ -21,6 +21,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme (ChatLayout (..))
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Compression (compress1)
import qualified Simplex.Messaging.Crypto as C
@@ -194,7 +195,7 @@ testProfile :: Profile
testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, description = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}
testGroupProfile :: GroupProfile
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing}
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing, chatLayout = Nothing}
shortLinkDataTests :: Spec
shortLinkDataTests = describe "Short link data encoding/decoding" $ do
@@ -353,6 +354,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.grp.inv with group link id" $
"{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing}
it "x.grp.info with chat layout" $
"{\"v\":\"9\",\"event\":\"x.grp.info\",\"params\":{\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}},\"chatLayout\":\"terminal\"}}}"
#==# XGrpInfo (testGroupProfile {chatLayout = Just CLTerminal} :: GroupProfile)
it "x.grp.info with unknown chat layout" $
"{\"v\":\"9\",\"event\":\"x.grp.info\",\"params\":{\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}},\"chatLayout\":\"wide\"}}}"
#==# XGrpInfo (testGroupProfile {chatLayout = Just $ CLUnknown "wide"} :: GroupProfile)
it "x.grp.acpt without incognito profile" $
"{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
#==# XGrpAcpt (MemberId "\1\2\3\4") Nothing Nothing