Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,40 @@ class ConversationSyncRepositoryIT {
assertFalse(unsynced.any { it.id == msg.id })
}

@Test
fun `message inserted with syncedAt is excluded from push queue`() {
// This is the proxy-path invariant: messages saved via the Askimo Team proxy
// are pre-marked synced at INSERT time so the sync push never re-uploads them.
val session = createSession()
val syncTime = Instant.now()

val msg = messageRepository.addMessage(
ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Proxy user message"),
syncedAt = syncTime,
)

assertFalse(
messageRepository.getUnsyncedMessages(session.id).any { it.id == msg.id },
"A message inserted with syncedAt must not appear in the unsynced push queue",
)
}

@Test
fun `message inserted without syncedAt still appears in push queue`() {
// Non-proxy messages (failed, local-only, non-ASKIMO_PRO) must still be pushed.
val session = createSession()

val msg = messageRepository.addMessage(
ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Regular message"),
syncedAt = null,
)

assertTrue(
messageRepository.getUnsyncedMessages(session.id).any { it.id == msg.id },
"A message inserted without syncedAt must appear in the unsynced push queue",
)
}

@Test
fun `respects limit parameter`() {
val session = createSession()
Expand Down
101 changes: 65 additions & 36 deletions desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import io.askimo.core.exception.ContextLengthException
import io.askimo.core.exception.ExceptionHandler
import io.askimo.core.logging.logger
import io.askimo.core.providers.ConfigurationErrorException
import io.askimo.core.providers.ModelProvider
import io.askimo.core.providers.ProxyChatContext
import io.askimo.core.providers.isContextLengthError
import io.askimo.core.providers.sendStreamingMessageWithCallback
import io.askimo.core.vision.ImageProcessor
Expand All @@ -41,6 +43,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

/**
Expand Down Expand Up @@ -295,47 +298,73 @@ class SessionManager(
var capturedTotalTokens: Int? = null
var capturedDurationMs: Long? = null

val fullResponse = chatSessionService
.getOrCreateClientForSession(sessionId)
.sendStreamingMessageWithCallback(
projectId = projectId,
userContents = promptWithContext,
enabledServerIds = enabledServerIds,
onToken = { token ->
streamingScope.launch {
thread.appendChunk(token)
}
},
onFollowUpSuggestion = { suggestion ->
log.debug("Follow-up suggestion for session $sessionId: ${suggestion.question}")
},
onTokenUsage = { input, output, total, durationMs ->
capturedInputTokens = input
capturedOutputTokens = output
capturedTotalTokens = total
capturedDurationMs = durationMs
log.debug("Token usage for session $sessionId: input=$input, output=$output, total=$total, duration=${durationMs}ms")
},
onToolStarted = { toolName, arguments ->
streamingScope.launch {
thread.markToolRunning(toolName, arguments)
}
},
onToolFinished = { toolName, arguments, result, hasFailed ->
streamingScope.launch {
thread.markToolDone(toolName, arguments, result, hasFailed)
}
},
onThinkingToken = { token ->
streamingScope.launch {
thread.appendThinkingChunk(token)
}
},
// Pre-generate the assistant message ID and set the ProxyChatContext
// ThreadLocal on this thread so the correlating HTTP client can inject
// the tracking headers into the outgoing proxy request.
// The ThreadLocal is safe here because sendStreamingMessageWithCallback is
// a blocking call that stays on this thread until the stream is complete.
val needsMessageCorrelation = AppContext.getInstance().getActiveProvider() == ModelProvider.ASKIMO_PRO
val assistantMessageId = if (needsMessageCorrelation) UUID.randomUUID().toString() else ""
if (needsMessageCorrelation) {
ProxyChatContext.set(
ProxyChatContext.Context(
sessionId = sessionId,
userMessageId = userMessage.id!!,
assistantMessageId = assistantMessageId,
),
)
}

val fullResponse = try {
chatSessionService
.getOrCreateClientForSession(sessionId)
.sendStreamingMessageWithCallback(
projectId = projectId,
userContents = promptWithContext,
enabledServerIds = enabledServerIds,
onToken = { token ->
streamingScope.launch {
thread.appendChunk(token)
}
},
onFollowUpSuggestion = { suggestion ->
log.debug("Follow-up suggestion for session $sessionId: ${suggestion.question}")
},
onTokenUsage = { input, output, total, durationMs ->
capturedInputTokens = input
capturedOutputTokens = output
capturedTotalTokens = total
capturedDurationMs = durationMs
log.debug("Token usage for session $sessionId: input=$input, output=$output, total=$total, duration=${durationMs}ms")
},
onToolStarted = { toolName, arguments ->
streamingScope.launch {
thread.markToolRunning(toolName, arguments)
}
},
onToolFinished = { toolName, arguments, result, hasFailed ->
streamingScope.launch {
thread.markToolDone(toolName, arguments, result, hasFailed)
}
},
onThinkingToken = { token ->
streamingScope.launch {
thread.appendThinkingChunk(token)
}
},
)
} finally {
// Always clear the ThreadLocal — prevents leaking context into
// subsequent requests on the same thread from the pool.
if (needsMessageCorrelation) ProxyChatContext.clear()
}

val savedMessage = chatSessionService.saveAiResponse(
sessionId = sessionId,
response = fullResponse,
// Pass the pre-generated ID so the server and local DB agree on
// the same message identity, and saveAiResponse can mark it synced.
messageId = assistantMessageId,
inputTokens = capturedInputTokens,
outputTokens = capturedOutputTokens,
totalTokens = capturedTotalTokens,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ enum class FeedbackReason(val emoji: String, val i18nKey: String) {
*
* Tracks sentiment via [Analytics.track] — respects the user's analytics opt-in.
* - Happy → [onHappy] caller shows the star/share prompt
* - Neutral → [onNeutral] caller dismisses
* - Unhappy → [onUnhappy] caller opens the contact/feedback page in the browser
* - Neutral → [onNeutral] caller shows [feedbackPromptDialog]
* `USER_SENTIMENT_NEUTRAL` is NOT fired here — deferred to [feedbackPromptDialog]
* and only fired when the user submits with a comment.
* - Unhappy → [onUnhappy] caller shows [feedbackPromptDialog] in unhappy path mode.
* `USER_SENTIMENT_UNHAPPY` is NOT fired here — deferred to [feedbackPromptDialog]
* and only fired when the user submits with a comment.
*/
@Composable
fun happinessGateDialog(
Expand Down Expand Up @@ -124,14 +128,12 @@ fun happinessGateDialog(
sentimentButton(
label = stringResource("happiness.gate.neutral"),
onClick = {
Analytics.track(AnalyticsEvent.USER_SENTIMENT_NEUTRAL)
onNeutral()
},
)
sentimentButton(
label = stringResource("happiness.gate.unhappy"),
onClick = {
Analytics.track(AnalyticsEvent.USER_SENTIMENT_UNHAPPY)
onUnhappy()
},
)
Expand Down Expand Up @@ -186,7 +188,12 @@ fun feedbackPromptDialog(
onClose: () -> Unit,
onSnooze: () -> Unit,
showReminderOnSkip: Boolean = true,
/** `"unhappy"`, `"neutral"`, or `null` (menu-opened). Controls copy and analytics gating. */
pathSentiment: String? = null,
) {
val isUnhappyPath = pathSentiment == "unhappy"
val isNeutralPath = pathSentiment == "neutral"
val isWeakSentimentPath = isUnhappyPath || isNeutralPath
var selectedReasons by remember { mutableStateOf(emptySet<FeedbackReason>()) }
var comment by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
Expand Down Expand Up @@ -261,12 +268,24 @@ fun feedbackPromptDialog(
verticalArrangement = Arrangement.spacedBy(Spacing.small),
) {
Text(
text = stringResource("feedback.dialog.title"),
text = stringResource(
when {
isUnhappyPath -> "feedback.unhappy.dialog.title"
isNeutralPath -> "feedback.neutral.dialog.title"
else -> "feedback.dialog.title"
},
),
style = AppTextStyles.sectionTitle,
textAlign = TextAlign.Center,
)
Text(
text = stringResource("feedback.dialog.subtitle"),
text = stringResource(
when {
isUnhappyPath -> "feedback.unhappy.dialog.subtitle"
isNeutralPath -> "feedback.neutral.dialog.subtitle"
else -> "feedback.dialog.subtitle"
},
),
style = AppTextStyles.bodySecondary,
textAlign = TextAlign.Center,
)
Expand Down Expand Up @@ -299,24 +318,47 @@ fun feedbackPromptDialog(
}
}
}
// ── Optional comment (required when MISSING_FEATURE or OTHER is selected) ────
// ── Comment field — encouraged on weak-sentiment paths, optional otherwise ──
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
modifier = Modifier.fillMaxWidth(),
label = {
Text(
text = stringResource("feedback.comment.label"),
text = stringResource(
when {
isUnhappyPath -> "feedback.comment.label.unhappy"
isNeutralPath -> "feedback.comment.label.neutral"
else -> "feedback.comment.label"
},
),
style = AppTextStyles.caption,
)
},
placeholder = {
Text(
text = stringResource("feedback.comment.placeholder"),
text = stringResource(
when {
isUnhappyPath -> "feedback.comment.placeholder.unhappy"
isNeutralPath -> "feedback.comment.placeholder.neutral"
else -> "feedback.comment.placeholder"
},
),
style = AppTextStyles.caption,
)
},
minLines = 3,
supportingText = if (isWeakSentimentPath) {
{
Text(
text = stringResource("feedback.comment.encourage.hint"),
style = AppTextStyles.caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
null
},
minLines = if (isWeakSentimentPath) 4 else 3,
maxLines = 5,
shape = MaterialTheme.shapes.medium,
)
Expand Down Expand Up @@ -357,6 +399,12 @@ fun feedbackPromptDialog(
}
Button(
onClick = {
if (comment.isNotBlank()) {
when {
isUnhappyPath -> Analytics.track(AnalyticsEvent.USER_SENTIMENT_UNHAPPY)
isNeutralPath -> Analytics.track(AnalyticsEvent.USER_SENTIMENT_NEUTRAL)
}
}
onSubmit(selectedReasons, comment.trim(), email.trim())
submitted = true
},
Expand Down
11 changes: 10 additions & 1 deletion desktop-shared/src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ action.refresh=Refresh

# Happiness Gate (shown before Star Prompt)
happiness.gate.title=How are you finding Askimo?
happiness.gate.subtitle=Let us know how we're doing
happiness.gate.subtitle=Your honest take helps us keep improving
happiness.gate.happy=Yes, I'm loving it! 😍
happiness.gate.neutral=It's okay 🙂
happiness.gate.unhappy=Not really 😕
Expand All @@ -535,6 +535,15 @@ feedback.dialog.title=What's not working for you?
feedback.dialog.subtitle=Select all that apply — your input goes straight to the team.
feedback.comment.label=Tell us more
feedback.comment.placeholder=Describe the issue or share your thoughts…
feedback.unhappy.dialog.title=We'd love to know what's not working
feedback.unhappy.dialog.subtitle=Tell us about your experience with Askimo so we can improve it.
feedback.comment.label.unhappy=What's not working for you?
feedback.comment.placeholder.unhappy=e.g. The session history is hard to find, responses feel slow…
feedback.neutral.dialog.title=What would make Askimo better for you?
feedback.neutral.dialog.subtitle=Your ideas help us figure out what to build next.
feedback.comment.label.neutral=What could we improve?
feedback.comment.placeholder.neutral=e.g. I wish it had better keyboard shortcuts, faster model switching…
feedback.comment.encourage.hint=A few words make a big difference — your feedback goes straight to the team.
feedback.email.label=Your email (optional)
feedback.email.placeholder=you@example.com
feedback.email.hint=Only used to follow up on your feedback. We'll never spam you.
Expand Down
11 changes: 10 additions & 1 deletion desktop-shared/src/main/resources/i18n/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ action.refresh=Aktualisieren

# Happiness Gate (shown before Star Prompt)
happiness.gate.title=Wie gefällt Ihnen Askimo?
happiness.gate.subtitle=Sagen Sie uns, wie wir uns machen
happiness.gate.subtitle=Ihr ehrliches Feedback hilft uns, uns stetig zu verbessern
happiness.gate.happy=Ich liebe es! 😍
happiness.gate.neutral=Es geht 🙂
happiness.gate.unhappy=Nicht wirklich 😕
Expand All @@ -535,6 +535,15 @@ feedback.dialog.title=Was funktioniert für dich nicht?
feedback.dialog.subtitle=Wähle alle zutreffenden Punkte aus — dein Feedback geht direkt an das Team.
feedback.comment.label=Erzähl uns mehr
feedback.comment.placeholder=Beschreibe das Problem oder teile uns deine Gedanken mit…
feedback.unhappy.dialog.title=Wir würden gerne wissen, was nicht funktioniert
feedback.unhappy.dialog.subtitle=Erzähl uns von deiner Erfahrung mit Askimo, damit wir es verbessern können.
feedback.comment.label.unhappy=Was funktioniert für dich nicht?
feedback.comment.placeholder.unhappy=z.B. Der Sitzungsverlauf ist schwer zu finden, Antworten fühlen sich langsam an…
feedback.neutral.dialog.title=Was würde Askimo für dich besser machen?
feedback.neutral.dialog.subtitle=Deine Ideen helfen uns zu entscheiden, was wir als Nächstes bauen.
feedback.comment.label.neutral=Was könnten wir verbessern?
feedback.comment.placeholder.neutral=z.B. Ich wünschte mir bessere Tastaturkürzel, schnellere Modellwechsel…
feedback.comment.encourage.hint=Ein paar Worte machen einen großen Unterschied — dein Feedback geht direkt an das Team.
feedback.email.label=Deine E-Mail (optional)
feedback.email.placeholder=you@example.com
feedback.email.hint=Wird nur verwendet, um bei deinem Feedback nachzufassen. Wir werden dich niemals zuspammen.
Expand Down
Loading