diff --git a/golem-xiv-api-backend/src/main/kotlin/CognitionApi.kt b/golem-xiv-api-backend/src/main/kotlin/CognitionApi.kt index c5e9b09..772df7f 100644 --- a/golem-xiv-api-backend/src/main/kotlin/CognitionApi.kt +++ b/golem-xiv-api-backend/src/main/kotlin/CognitionApi.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.api.backend +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.api.EpistemicAgent import com.xemantic.ai.golem.api.PhenomenalExpression import com.xemantic.ai.golem.api.Phenomenon @@ -111,6 +112,15 @@ interface CognitionRepository { cognitionId: Long ): Phenomenon.Intent? + suspend fun listCognitions( + limit: Int = 50, + offset: Int = 0 + ): List + + suspend fun isFirstExpression( + cognitionId: Long + ): Boolean + } interface CognitiveMemory { @@ -178,6 +188,15 @@ interface CognitiveMemory { type: StorageType ): String + suspend fun listCognitions( + limit: Int = 50, + offset: Int = 0 + ): List + + suspend fun getExpressionCount( + cognitionId: Long + ): Int + } data class CulminatedWithIntent( diff --git a/golem-xiv-api-backend/src/main/kotlin/TitleGenerator.kt b/golem-xiv-api-backend/src/main/kotlin/TitleGenerator.kt new file mode 100644 index 0000000..657fe9d --- /dev/null +++ b/golem-xiv-api-backend/src/main/kotlin/TitleGenerator.kt @@ -0,0 +1,25 @@ +/* + * Golem XIV - Autonomous metacognitive AI system with semantic memory and self-directed research + * Copyright (C) 2025 Kazimierz Pogoda / Xemantic + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.xemantic.ai.golem.api.backend + +interface TitleGenerator { + + suspend fun generateTitle(userMessage: String): String + +} diff --git a/golem-xiv-api-client/src/commonMain/kotlin/GolemServices.kt b/golem-xiv-api-client/src/commonMain/kotlin/GolemServices.kt index fa26076..e537b27 100644 --- a/golem-xiv-api-client/src/commonMain/kotlin/GolemServices.kt +++ b/golem-xiv-api-client/src/commonMain/kotlin/GolemServices.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.api.client +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.api.GolemError import com.xemantic.ai.golem.api.Phenomenon @@ -51,6 +52,11 @@ interface CognitionService { phenomena: List ) + suspend fun listCognitions( + limit: Int = 50, + offset: Int = 0 + ): List + } // TODO should be rather exception hierarchy diff --git a/golem-xiv-api-client/src/commonMain/kotlin/http/CognitionService.kt b/golem-xiv-api-client/src/commonMain/kotlin/http/CognitionService.kt index 5afd416..d23aea4 100644 --- a/golem-xiv-api-client/src/commonMain/kotlin/http/CognitionService.kt +++ b/golem-xiv-api-client/src/commonMain/kotlin/http/CognitionService.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.api.client.http +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.api.Phenomenon import com.xemantic.ai.golem.api.client.CognitionService import io.ktor.client.HttpClient @@ -47,4 +48,11 @@ class HttpClientCognitionService( ) } + override suspend fun listCognitions( + limit: Int, + offset: Int + ): List = client.serviceGet( + uri = "/api/cognitions?limit=$limit&offset=$offset" + ) + } diff --git a/golem-xiv-api/src/commonMain/kotlin/Cognition.kt b/golem-xiv-api/src/commonMain/kotlin/Cognition.kt index 6cd81a5..203747a 100644 --- a/golem-xiv-api/src/commonMain/kotlin/Cognition.kt +++ b/golem-xiv-api/src/commonMain/kotlin/Cognition.kt @@ -102,3 +102,10 @@ sealed interface Phenomenon { ) : Phenomenon } + +@Serializable +data class CognitionListItem( + val id: Long, + val title: String?, + val initiationMoment: Instant +) diff --git a/golem-xiv-api/src/commonMain/kotlin/GolemOutput.kt b/golem-xiv-api/src/commonMain/kotlin/GolemOutput.kt index 316c224..830ca95 100644 --- a/golem-xiv-api/src/commonMain/kotlin/GolemOutput.kt +++ b/golem-xiv-api/src/commonMain/kotlin/GolemOutput.kt @@ -63,4 +63,11 @@ sealed interface GolemOutput { val event: CognitionEvent ) : GolemOutput, WithCognitionId + @Serializable + @SerialName("CognitionTitleUpdated") + data class CognitionTitleUpdated( + override val cognitionId: Long, + val title: String + ) : GolemOutput, WithCognitionId + } diff --git a/golem-xiv-cognizer-anthropic/src/main/kotlin/AnthropicTitleGenerator.kt b/golem-xiv-cognizer-anthropic/src/main/kotlin/AnthropicTitleGenerator.kt new file mode 100644 index 0000000..9c0820e --- /dev/null +++ b/golem-xiv-cognizer-anthropic/src/main/kotlin/AnthropicTitleGenerator.kt @@ -0,0 +1,50 @@ +/* + * Golem XIV - Autonomous metacognitive AI system with semantic memory and self-directed research + * Copyright (C) 2025 Kazimierz Pogoda / Xemantic + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.xemantic.ai.golem.cognizer.anthropic + +import com.xemantic.ai.anthropic.Anthropic +import com.xemantic.ai.anthropic.content.Text +import com.xemantic.ai.anthropic.message.Message +import com.xemantic.ai.anthropic.message.Role +import com.xemantic.ai.golem.api.backend.TitleGenerator + +class AnthropicTitleGenerator( + private val anthropic: Anthropic +) : TitleGenerator { + + override suspend fun generateTitle(userMessage: String): String { + val response = anthropic.messages.create { + maxTokens = 30 + messages = listOf( + Message { + role = Role.USER + content = listOf(Text(""" + Generate a concise title (max 6 words) for a conversation starting with: + + "$userMessage" + + Reply with only the title, no quotes or extra punctuation. + """.trimIndent())) + } + ) + } + return (response.content.first() as Text).text.trim() + } + +} diff --git a/golem-xiv-core/src/main/kotlin/GolemXiv.kt b/golem-xiv-core/src/main/kotlin/GolemXiv.kt index 7624b82..fed2838 100644 --- a/golem-xiv-core/src/main/kotlin/GolemXiv.kt +++ b/golem-xiv-core/src/main/kotlin/GolemXiv.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.core +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.api.Phenomenon import com.xemantic.ai.golem.api.CognitionEvent import com.xemantic.ai.golem.api.EpistemicAgent @@ -25,6 +26,7 @@ import com.xemantic.ai.golem.api.GolemOutput import com.xemantic.ai.golem.api.backend.CognitionRepository import com.xemantic.ai.golem.api.backend.Cognizer import com.xemantic.ai.golem.api.backend.AgentIdentity +import com.xemantic.ai.golem.api.backend.TitleGenerator import com.xemantic.ai.golem.api.backend.script.ExecuteGolemScript import com.xemantic.ai.golem.core.kotlin.getClasspathResource import com.xemantic.ai.golem.core.script.GolemScriptExecutor @@ -55,6 +57,7 @@ class GolemXiv( private val identity: AgentIdentity, private val repository: CognitionRepository, private val cognizer: Cognizer, + private val titleGenerator: TitleGenerator?, private val golemScriptDependencyProvider: GolemScriptDependencyProvider, private val outputs: FlowCollector ) : AutoCloseable { @@ -84,6 +87,11 @@ class GolemXiv( return info.id } + suspend fun listCognitions( + limit: Int = 50, + offset: Int = 0 + ): List = repository.listCognitions(limit, offset) + /** * @throws com.xemantic.ai.golem.api.backend.GolemException */ @@ -107,6 +115,22 @@ class GolemXiv( cognitionBroadcaster.emit(phenomenalExpression) + // Generate title for first user message + if (titleGenerator != null && repository.isFirstExpression(cognitionId)) { + val firstText = phenomena.filterIsInstance().firstOrNull() + if (firstText != null) { + scope.launch { + try { + val title = titleGenerator.generateTitle(firstText.text) + repository.getCognition(cognitionId).setTitle(title) + outputs.emit(GolemOutput.CognitionTitleUpdated(cognitionId, title)) + } catch (e: Exception) { + logger.warn(e) { "Failed to generate title for cognition $cognitionId" } + } + } + } + } + activeCognitionMap[cognitionId] = scope.launch { logger.debug { "Cognition[$cognitionId]: Initiating cognition" } diff --git a/golem-xiv-core/src/main/kotlin/cognition/DefaultCognitionRepository.kt b/golem-xiv-core/src/main/kotlin/cognition/DefaultCognitionRepository.kt index 275dbba..5d384cb 100644 --- a/golem-xiv-core/src/main/kotlin/cognition/DefaultCognitionRepository.kt +++ b/golem-xiv-core/src/main/kotlin/cognition/DefaultCognitionRepository.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.core.cognition +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.api.EpistemicAgent import com.xemantic.ai.golem.api.PhenomenalExpression import com.xemantic.ai.golem.api.Phenomenon @@ -307,4 +308,13 @@ class DefaultCognitionRepository( } } + override suspend fun listCognitions( + limit: Int, + offset: Int + ): List = memory.listCognitions(limit, offset) + + override suspend fun isFirstExpression( + cognitionId: Long + ): Boolean = memory.getExpressionCount(cognitionId) == 1 + } diff --git a/golem-xiv-neo4j/src/main/kotlin/Neo4jCognitiveMemory.kt b/golem-xiv-neo4j/src/main/kotlin/Neo4jCognitiveMemory.kt index d4268cf..d42eaf7 100644 --- a/golem-xiv-neo4j/src/main/kotlin/Neo4jCognitiveMemory.kt +++ b/golem-xiv-neo4j/src/main/kotlin/Neo4jCognitiveMemory.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.neo4j +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.api.EpistemicAgent import com.xemantic.ai.golem.api.PhenomenalExpression import com.xemantic.ai.golem.api.Phenomenon @@ -32,6 +33,7 @@ import com.xemantic.neo4j.driver.singleOrNull import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList import org.neo4j.driver.types.Node class Neo4jCognitiveMemory( @@ -461,6 +463,47 @@ class Neo4jCognitiveMemory( } } + override suspend fun listCognitions( + limit: Int, + offset: Int + ): List = neo4j.read { tx -> + tx.run( + query = $$""" + MATCH (c:Cognition) + WHERE NOT EXISTS { (parent:Cognition)-[:hasChild]->(c) } + RETURN id(c) AS id, c.title AS title, c.initiationMoment AS initiationMoment + ORDER BY c.initiationMoment DESC + SKIP $offset + LIMIT $limit + """.trimIndent(), + parameters = mapOf( + "offset" to offset, + "limit" to limit + ) + ).records().toList().map { record -> + CognitionListItem( + id = record["id"].asLong(), + title = record["title"]?.let { if (it.isNull) null else it.asString() }, + initiationMoment = record["initiationMoment"].asInstant() + ) + } + } + + override suspend fun getExpressionCount( + cognitionId: Long + ): Int = neo4j.read { tx -> + tx.run( + query = $$""" + MATCH (c:Cognition)-[:hasPart]->(e:PhenomenalExpression) + WHERE id(c) = $cognitionId + RETURN count(e) AS count + """.trimIndent(), + parameters = mapOf( + "cognitionId" to cognitionId + ) + ).single()["count"].asInt() + } + } private fun StorageType.toPropertyName(): String = when (this) { diff --git a/golem-xiv-neo4j/src/test/kotlin/Neo4jCognitiveMemoryTest.kt b/golem-xiv-neo4j/src/test/kotlin/Neo4jCognitiveMemoryTest.kt index e76d4a1..6f191ce 100644 --- a/golem-xiv-neo4j/src/test/kotlin/Neo4jCognitiveMemoryTest.kt +++ b/golem-xiv-neo4j/src/test/kotlin/Neo4jCognitiveMemoryTest.kt @@ -114,4 +114,146 @@ class Neo4jCognitiveMemoryTest { } } + @Test + fun `should list cognitions ordered by initiation moment descending`() = runTest { + // given + val memory = Neo4jCognitiveMemory( + neo4j = TestNeo4j.operations + ) + + val cognition1 = memory.createCognition(constitution = listOf("First")) + val cognition2 = memory.createCognition(constitution = listOf("Second")) + val cognition3 = memory.createCognition(constitution = listOf("Third")) + + // when + val cognitions = memory.listCognitions() + + // then + cognitions should { + have(size == 3) + have(get(0).id == cognition3.id) // most recent first + have(get(1).id == cognition2.id) + have(get(2).id == cognition1.id) + } + } + + @Test + fun `should list cognitions excluding child cognitions`() = runTest { + // given + val memory = Neo4jCognitiveMemory( + neo4j = TestNeo4j.operations + ) + + val parentCognition = memory.createCognition(constitution = listOf("Parent")) + val childCognition = memory.createCognition( + constitution = listOf("Child"), + parentId = parentCognition.id + ) + val standaloneCognition = memory.createCognition(constitution = listOf("Standalone")) + + // when + val cognitions = memory.listCognitions() + + // then + cognitions should { + have(size == 2) // only parent and standalone, not child + have(any { it.id == parentCognition.id }) + have(any { it.id == standaloneCognition.id }) + have(none { it.id == childCognition.id }) + } + } + + @Test + fun `should list cognitions with title`() = runTest { + // given + val memory = Neo4jCognitiveMemory( + neo4j = TestNeo4j.operations + ) + + val cognition = memory.createCognition(constitution = listOf("Test")) + memory.setCognitionTitle(cognition.id, "My Cognition Title") + + // when + val cognitions = memory.listCognitions() + + // then + cognitions should { + have(size == 1) + have(get(0).title == "My Cognition Title") + } + } + + @Test + fun `should list cognitions with limit and offset`() = runTest { + // given + val memory = Neo4jCognitiveMemory( + neo4j = TestNeo4j.operations + ) + + repeat(5) { + memory.createCognition(constitution = listOf("Cognition $it")) + } + + // when + val firstPage = memory.listCognitions(limit = 2, offset = 0) + val secondPage = memory.listCognitions(limit = 2, offset = 2) + + // then + firstPage should { have(size == 2) } + secondPage should { have(size == 2) } + firstPage.map { it.id } should { have(none { id -> secondPage.any { it.id == id } }) } + } + + @Test + fun `should return expression count of zero for new cognition`() = runTest { + // given + val memory = Neo4jCognitiveMemory( + neo4j = TestNeo4j.operations + ) + val cognition = memory.createCognition(constitution = listOf("Test")) + + // when + val count = memory.getExpressionCount(cognition.id) + + // then + count should { have(this == 0) } + } + + @Test + fun `should return correct expression count`() = runTest { + // given + val memory = Neo4jCognitiveMemory( + neo4j = TestNeo4j.operations + ) + val cognition = memory.createCognition(constitution = listOf("Test")) + + // create an agent and expressions directly via Cypher + val cognitionId = cognition.id + TestNeo4j.operations.write { tx -> + tx.run( + """ + CREATE (agent:EpistemicAgent:Human) + WITH agent + MATCH (c:Cognition) WHERE id(c) = ${'$'}cognitionId + CREATE (e1:PhenomenalExpression {initiationMoment: datetime()}) + CREATE (e2:PhenomenalExpression {initiationMoment: datetime()}) + CREATE (e3:PhenomenalExpression {initiationMoment: datetime()}) + CREATE (agent)-[:creator]->(e1) + CREATE (agent)-[:creator]->(e2) + CREATE (agent)-[:creator]->(e3) + CREATE (c)-[:hasPart]->(e1) + CREATE (c)-[:hasPart]->(e2) + CREATE (c)-[:hasPart]->(e3) + """.trimIndent(), + mapOf("cognitionId" to cognitionId) + ) + } + + // when + val count = memory.getExpressionCount(cognition.id) + + // then + count should { have(this == 3) } + } + } diff --git a/golem-xiv-presenter/src/commonMain/kotlin/MainPresenter.kt b/golem-xiv-presenter/src/commonMain/kotlin/MainPresenter.kt index d1a5d51..5854063 100644 --- a/golem-xiv-presenter/src/commonMain/kotlin/MainPresenter.kt +++ b/golem-xiv-presenter/src/commonMain/kotlin/MainPresenter.kt @@ -114,6 +114,10 @@ class MainPresenter( private var currentNavigationTarget: Navigation.Target? = null + private val cognitionService = HttpClientCognitionService(apiClient) + + private val golemOutputs = MutableSharedFlow() + val headerPresenter = HeaderPresenter( scope, headerView, @@ -125,13 +129,12 @@ class MainPresenter( sidebarView, toggles = toggleFlow, navigation, - themeChangesSink = themeChangesFlow + themeChangesSink = themeChangesFlow, + cognitionService = cognitionService, + golemOutputs = golemOutputs ) - private val golemOutputs = MutableSharedFlow() - private val pingService = HttpClientPingService(apiClient) - private val cognitionService = HttpClientCognitionService(apiClient) private lateinit var cognitionPresenter: CognitionPresenter private lateinit var cognitionView: CognitionView diff --git a/golem-xiv-presenter/src/commonMain/kotlin/navigation/SidebarPresenter.kt b/golem-xiv-presenter/src/commonMain/kotlin/navigation/SidebarPresenter.kt index ee8a962..158a24f 100644 --- a/golem-xiv-presenter/src/commonMain/kotlin/navigation/SidebarPresenter.kt +++ b/golem-xiv-presenter/src/commonMain/kotlin/navigation/SidebarPresenter.kt @@ -18,13 +18,18 @@ package com.xemantic.ai.golem.presenter.navigation +import com.xemantic.ai.golem.api.CognitionListItem +import com.xemantic.ai.golem.api.GolemOutput +import com.xemantic.ai.golem.api.client.CognitionService import com.xemantic.ai.golem.presenter.environment.Theme import com.xemantic.ai.golem.presenter.util.Action import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch interface SidebarView { @@ -36,8 +41,12 @@ interface SidebarView { val resizes: Flow + val cognitionSelections: Flow + fun themeActionLabel(theme: Theme) + fun updateCognitionList(cognitions: List) + var opened: Boolean } @@ -47,7 +56,9 @@ class SidebarPresenter( private val view: SidebarView, toggles: Flow, navigation: Navigation, - themeChangesSink: FlowCollector + themeChangesSink: FlowCollector, + private val cognitionService: CognitionService, + golemOutputs: Flow ) { var opened: Boolean = false @@ -84,6 +95,30 @@ class SidebarPresenter( view.opened = opened }.launchIn(scope) + view.cognitionSelections.onEach { cognitionId -> + navigation.navigateTo(Navigation.Target.Cognition(cognitionId)) + }.launchIn(scope) + + // Load initial cognition list + scope.launch { + refreshCognitionList() + } + + // Refresh on title updates + golemOutputs.filterIsInstance().onEach { + refreshCognitionList() + }.launchIn(scope) + + // Refresh on new cognitions + golemOutputs.filterIsInstance().onEach { + refreshCognitionList() + }.launchIn(scope) + + } + + private suspend fun refreshCognitionList() { + val cognitions = cognitionService.listCognitions() + view.updateCognitionList(cognitions) } } diff --git a/golem-xiv-server/src/main/kotlin/GolemServer.kt b/golem-xiv-server/src/main/kotlin/GolemServer.kt index 2e36476..a80f135 100644 --- a/golem-xiv-server/src/main/kotlin/GolemServer.kt +++ b/golem-xiv-server/src/main/kotlin/GolemServer.kt @@ -23,6 +23,7 @@ import com.xemantic.ai.golem.api.GolemError import com.xemantic.ai.golem.api.GolemOutput import com.xemantic.ai.golem.api.Phenomenon import com.xemantic.ai.golem.api.backend.GolemException +import com.xemantic.ai.golem.cognizer.anthropic.AnthropicTitleGenerator import com.xemantic.ai.golem.cognizer.anthropic.AnthropicToolUseCognizer import com.xemantic.ai.golem.core.GolemXiv import com.xemantic.ai.golem.core.cognition.DefaultCognitionRepository @@ -131,10 +132,13 @@ fun Application.module() { } ) + val titleGenerator = AnthropicTitleGenerator(anthropic) + val golem = GolemXiv( identity = identity, repository = repository, cognizer = anthropicCognizer, + titleGenerator = titleGenerator, golemScriptDependencyProvider = golemScriptDependencyProvider, outputs = outputs ) @@ -201,9 +205,9 @@ fun Route.golemApiRoute( } get("/cognitions") { -// call.respond( -// golem.contexts -// ) + val limit = call.parameters["limit"]?.toIntOrNull() ?: 50 + val offset = call.parameters["offset"]?.toIntOrNull() ?: 0 + call.respond(golem.listCognitions(limit, offset)) } put("/cognitions") { diff --git a/golem-xiv-web/src/jsMain/kotlin/navigation/HtmlNavigationRailView.kt b/golem-xiv-web/src/jsMain/kotlin/navigation/HtmlNavigationRailView.kt index c171874..e149e01 100644 --- a/golem-xiv-web/src/jsMain/kotlin/navigation/HtmlNavigationRailView.kt +++ b/golem-xiv-web/src/jsMain/kotlin/navigation/HtmlNavigationRailView.kt @@ -18,6 +18,7 @@ package com.xemantic.ai.golem.web.navigation +import com.xemantic.ai.golem.api.CognitionListItem import com.xemantic.ai.golem.presenter.environment.Theme import com.xemantic.ai.golem.presenter.navigation.SidebarView import com.xemantic.ai.golem.presenter.util.Action @@ -28,12 +29,14 @@ import com.xemantic.ai.golem.web.js.dom import com.xemantic.ai.golem.web.js.inject import com.xemantic.ai.golem.web.js.resizes import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import com.xemantic.ai.golem.web.view.HasRootHtmlElement import kotlinx.browser.window -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.html.* import org.w3c.dom.HTMLElement @@ -70,6 +73,16 @@ class HtmlNavigationRailView : SidebarView, HasRootHtmlElement { i { +"light_mode" } } + private val cognitionListContainer = dom.div(classes = "cognition-list scroll") {} + + private val cognitionListSection = dom.div(classes = "cognition-list-section") { + div(classes = "small-text bold padding") { +"Past Cognitions" } + inject(cognitionListContainer) + } + + private val _cognitionSelections = MutableSharedFlow() + override val cognitionSelections: Flow = _cognitionSelections + override val element: HTMLElement = dom.nav(classes = "app-navigation-rail left surface-container") { ariaLabel = "Main navigation" header { @@ -78,7 +91,8 @@ class HtmlNavigationRailView : SidebarView, HasRootHtmlElement { inject( initiateCognitionItem, memoryItem, - settingsItem + settingsItem, + cognitionListSection ) div(classes = "max") inject(themeSwitcherButton) @@ -116,4 +130,22 @@ class HtmlNavigationRailView : SidebarView, HasRootHtmlElement { } } + override fun updateCognitionList(cognitions: List) { + cognitionListContainer.innerHTML = "" + cognitions.forEach { cognition -> + val displayTitle = cognition.title?.takeIf { it.isNotBlank() } + ?: "Cognition #${cognition.id}" + val item = dom.a(classes = "row wave") { + div(classes = "max") { +displayTitle } + } + item.onclick = { + MainScope().launch { + _cognitionSelections.emit(cognition.id) + } + Unit + } + cognitionListContainer.appendChild(item) + } + } + } diff --git a/golem-xiv-web/src/jsMain/resources/css/main.css b/golem-xiv-web/src/jsMain/resources/css/main.css index 89f0bd1..b47fb9b 100644 --- a/golem-xiv-web/src/jsMain/resources/css/main.css +++ b/golem-xiv-web/src/jsMain/resources/css/main.css @@ -70,6 +70,28 @@ pre { margin-bottom: .9rem; } +/* Past cognitions list - only visible when expanded */ +.app-navigation-rail:not(.max) .cognition-list-section { + display: none; +} + +.cognition-list-section { + border-top: 1px solid var(--outline-variant); + margin-top: .5rem; + padding-top: .5rem; +} + +.cognition-list { + max-height: 500px; +} + +.cognition-list a { + padding: .5rem 1rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + /* Navigation components */ .app-navigation-bar { z-index: 200 !important;