git.iko.soy/opencode-android
on 06fd41b help sign in
676 lines 32 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
7import androidx.compose.foundation.horizontalScroll
8import androidx.compose.foundation.rememberScrollState
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.defaultMinSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.text.selection.SelectionContainer
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.HelpOutline
18import androidx.compose.material.icons.filled.Bolt
19import androidx.compose.material.icons.filled.CheckCircle
20import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.Description
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Psychology
26import androidx.compose.material.icons.filled.Schedule
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.Icon
29import androidx.compose.material3.IconButton
30import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
33import androidx.compose.runtime.Composable
34import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.getValue
36import androidx.compose.runtime.mutableStateOf
37import androidx.compose.runtime.remember
38import androidx.compose.runtime.saveable.rememberSaveable
39import androidx.compose.runtime.setValue
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
42import androidx.compose.ui.draw.clip
43import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44import androidx.compose.ui.platform.LocalContext
45import androidx.compose.ui.platform.LocalHapticFeedback
46import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.semantics
49import androidx.compose.ui.semantics.stateDescription
50import androidx.compose.ui.text.font.FontFamily
51import androidx.compose.ui.res.pluralStringResource
52import androidx.compose.ui.res.stringResource
53import androidx.compose.ui.unit.dp
54import kotlinx.serialization.json.Json
55import kotlinx.serialization.json.JsonElement
56import soy.iko.opencode.data.model.FilePart
57import soy.iko.opencode.data.model.Part
58import soy.iko.opencode.data.model.ReasoningPart
59import soy.iko.opencode.data.model.StepFinishPart
60import soy.iko.opencode.data.model.StepStartPart
61import soy.iko.opencode.data.model.TextPart
62import soy.iko.opencode.data.model.ToolCompleted
63import soy.iko.opencode.data.model.ToolError
64import soy.iko.opencode.data.model.ToolPart
65import soy.iko.opencode.data.model.ToolPending
66import soy.iko.opencode.data.model.ToolRunning
67import soy.iko.opencode.data.model.ToolState
68import soy.iko.opencode.data.model.ToolUnknown
69import soy.iko.opencode.data.model.TODO_WRITE_TOOL
70import soy.iko.opencode.data.model.UnknownPart
71import soy.iko.opencode.data.model.inputElement
72import soy.iko.opencode.data.model.parseTodos
73import soy.iko.opencode.data.model.sourcePath
74import soy.iko.opencode.ui.components.DiffView
75import soy.iko.opencode.ui.components.ImageLoadContext
76import soy.iko.opencode.ui.components.MarkdownText
77import soy.iko.opencode.ui.components.RemoteImage
78import soy.iko.opencode.ui.components.copyToClipboard
79import soy.iko.opencode.ui.components.isImage
80import soy.iko.opencode.ui.components.looksLikeDiff
81import soy.iko.opencode.ui.components.rememberVisibilityTransitions
82import soy.iko.opencode.ui.components.showCopyToast
83import soy.iko.opencode.R
85private const val COLLAPSED_LIMIT = 4000
87// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
88// resilient decoding) so this stays human-readable; constructed lazily and memoized.
89private val prettyJson: Json = Json { prettyPrint = true }
91/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
92private fun ToolState.titleText(): String? = when (this) {
93 is ToolRunning -> title
94 is ToolCompleted -> title
95 else -> null
98/** The elapsed wall-clock seconds a tool state ran for, when its time window is known.
99 * Returns null while a tool is still pending or when the server didn't report timing. */
100private fun ToolState.durationMs(): Long? {
101 val time = when (this) {
102 is ToolRunning -> time
103 is ToolCompleted -> time
104 is ToolError -> time
105 else -> return null
106 } ?: return null
107 val start = time.start ?: return null
108 val end = time.end ?: return null
109 return (end - start).coerceAtLeast(0L)
112/** Compact human-readable duration for a tool call (e.g. "2.3s", "1m 04s"). Sub-second
113 * runs render in milliseconds so a quick read still shows something distinguishable. */
114fun formatToolDuration(ms: Long): String {
115 if (ms < 1_000) return "${ms}ms"
116 val totalSeconds = ms / 1_000
117 val tenths = (ms % 1_000) / 100
118 if (totalSeconds < 60) {
119 return if (tenths == 0L) "${totalSeconds}s" else "${totalSeconds}.${tenths}s"
120 }
121 val minutes = totalSeconds / 60
122 val seconds = totalSeconds % 60
123 return if (minutes < 60) {
124 "${minutes}m ${seconds.toString().padStart(2, '0')}s"
125 } else {
126 val hours = minutes / 60
127 val mins = minutes % 60
128 "${hours}h ${mins.toString().padStart(2, '0')}m"
129 }
132/**
133 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
134 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
135 *
136 * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
137 * source path the viewer can open; null leaves the chip as a copy-path affordance
138 * (used when no file navigation is wired, e.g. in tests).
139 */
140@Composable
141fun PartView(
142 part: Part,
143 modifier: Modifier = Modifier,
144 isRunning: Boolean = false,
145 imageContext: ImageLoadContext? = null,
146 onOpenFile: ((path: String) -> Unit)? = null,
147) {
148 when (part) {
149 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
150 // A synthetic TextPart is server-generated content (e.g. a compacted-conversation
151 // summary) standing in for real exchanged messages. Mark it with a small muted
152 // chip above the body so a reader can tell it isn't a verbatim assistant reply —
153 // important when reviewing a compacted history where the "summary" framing would
154 // otherwise be indistinguishable from the agent speaking in its own voice.
155 if (part.synthetic) {
156 Row(
157 modifier = modifier.fillMaxWidth(),
158 verticalAlignment = Alignment.CenterVertically,
159 ) {
160 Text(
161 stringResource(R.string.synthetic_chip),
162 style = MaterialTheme.typography.labelSmall,
163 color = MaterialTheme.colorScheme.onSurfaceVariant,
164 modifier = Modifier
165 .clip(MaterialTheme.shapes.extraSmall)
166 .background(MaterialTheme.colorScheme.surfaceVariant)
167 .padding(horizontal = 6.dp, vertical = 2.dp),
168 )
169 }
170 }
171 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
172 }
173 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
174 is ToolPart -> ToolCallView(part, modifier)
175 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
176 RemoteImage(part, imageContext, modifier)
177 } else {
178 FileChip(part, modifier, onOpenFile)
179 }
180 is StepStartPart -> {} // boundary marker — nothing to draw
181 is StepFinishPart -> {} // metrics handled at message level
182 is UnknownPart -> UnknownPartNote(modifier)
183 }
186@Composable
187private fun UnknownPartNote(modifier: Modifier) {
188 // Forward-compat: a part type the client doesn't model. Render a muted note (matching
189 // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
190 // the part vanishing silently — important when a new server release adds a part type.
191 Row(
192 modifier = modifier.fillMaxWidth(),
193 verticalAlignment = Alignment.CenterVertically,
194 ) {
195 Icon(
196 Icons.AutoMirrored.Filled.HelpOutline,
197 contentDescription = null,
198 modifier = Modifier.size(14.dp),
199 tint = MaterialTheme.colorScheme.onSurfaceVariant,
200 )
201 Text(
202 stringResource(R.string.unknown_part),
203 style = MaterialTheme.typography.labelSmall,
204 color = MaterialTheme.colorScheme.onSurfaceVariant,
205 modifier = Modifier.padding(start = 6.dp),
206 )
207 }
210@Composable
211private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
212 if (text.isBlank()) return
213 val context = LocalContext.current
214 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
215 // inherit the positional saveable key of a prior block. A content-prefix key would
216 // change on nearly every token while streaming and snap the block back to collapsed;
217 // when the part has no id, use a constant — the key(part.id, index) wrapper at the
218 // call site already disambiguates sibling parts positionally, so a constant per
219 // block type won't collide.
220 val saveableKey = keyId.ifBlank { "reasoning" }
221 val haptics = LocalHapticFeedback.current
222 // While the reasoning is still streaming, default to expanded so live "Thinking…" is
223 // visible; once complete it collapses. A non-null override records an explicit user
224 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
225 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
226 // Sticky expand: once a block has streamed this session, pin it open after streaming ends
227 // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
228 // stream finishes. A historical block that never streamed this session (streaming is false
229 // from the first composition) never sets streamedThisSession, so it stays collapsed.
230 var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
231 LaunchedEffect(streaming) {
232 if (streaming) streamedThisSession = true
233 else if (streamedThisSession && userOverride == null) userOverride = true
234 }
235 val expanded = userOverride ?: streaming
236 val expandedState = stringResource(R.string.state_expanded)
237 val collapsedState = stringResource(R.string.state_collapsed)
238 val thinkingLabel = stringResource(R.string.thinking)
239 val thoughtsLabel = stringResource(R.string.thoughts)
240 // Hoist the collapsed word count so it can drive both the visible "(N)" label and the
241 // row's a11y stateDescription. Previously the count rendered as a bare "(12)" that
242 // TalkBack read as "12" with no context; folding it into the stateDescription makes the
243 // hidden-content size reachable to screen-reader users.
244 val collapsedWordCount = remember(text) {
245 text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
246 }
247 // Resolve the readable "N words hidden" form once (in the composable body — the semantics
248 // lambda below is not a composable scope, so it can't call pluralStringResource itself).
249 val collapsedWordsLabel = pluralStringResource(
250 R.plurals.reasoning_word_count,
251 collapsedWordCount,
252 collapsedWordCount,
253 )
254 Column(modifier = modifier.fillMaxWidth()) {
255 Row(
256 modifier = Modifier
257 .fillMaxWidth()
258 .defaultMinSize(minHeight = 48.dp)
259 .clickable(role = Role.Button) { userOverride = !expanded }
260 .padding(vertical = 4.dp)
261 .semantics {
262 // When collapsed (and not streaming), append how much reasoning is hidden
263 // so the state reads e.g. "Collapsed, 42 words hidden" — giving a TalkBack
264 // user the same size signal the visible "(42)" gives a sighted user.
265 stateDescription = when {
266 expanded -> expandedState
267 streaming -> collapsedState
268 else -> "$collapsedState, $collapsedWordsLabel"
269 }
270 },
271 verticalAlignment = Alignment.CenterVertically,
272 ) {
273 if (streaming) {
274 CircularProgressIndicator(
275 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
276 strokeWidth = 2.dp,
277 color = MaterialTheme.colorScheme.onSurfaceVariant,
278 )
279 } else {
280 Icon(
281 Icons.Filled.Psychology,
282 contentDescription = null,
283 modifier = Modifier.size(16.dp),
284 tint = MaterialTheme.colorScheme.onSurfaceVariant,
285 )
286 }
287 Text(
288 if (streaming) thinkingLabel else thoughtsLabel,
289 style = MaterialTheme.typography.labelMedium,
290 color = MaterialTheme.colorScheme.onSurfaceVariant,
291 modifier = Modifier.padding(start = 6.dp),
292 )
293 // When collapsed, show a muted word count so the user has a signal of how much
294 // content is hidden before deciding to expand. Skipped while streaming (the
295 // content is growing and the label already says "Thinking…").
296 if (!streaming && !expanded) {
297 Text(
298 "($collapsedWordCount)",
299 style = MaterialTheme.typography.labelSmall,
300 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
301 modifier = Modifier.padding(start = 4.dp),
302 )
303 }
304 }
305 val expandMotion = rememberVisibilityTransitions()
306 AnimatedVisibility(
307 visible = expanded,
308 enter = expandMotion.enter,
309 exit = expandMotion.exit,
310 ) {
311 Column {
312 // Wrap in SelectionContainer so the user can select a portion of the
313 // reasoning (e.g. a single step) instead of the all-or-nothing copy
314 // button below. Matches the markdown text's selectability.
315 SelectionContainer {
316 Text(
317 text,
318 style = MaterialTheme.typography.bodySmall,
319 color = MaterialTheme.colorScheme.onSurfaceVariant,
320 )
321 }
322 }
323 }
324 // Copy affordance lives outside AnimatedVisibility so it's reachable when the
325 // block is collapsed — previously the copy button was inside the expanded content,
326 // leaving a collapsed reasoning block with no copy affordance at all.
327 TextButton(
328 onClick = {
329 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
330 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
331 },
332 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
333 modifier = Modifier
334 .defaultMinSize(minHeight = 48.dp)
335 .semantics(mergeDescendants = true) {},
336 ) {
337 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
338 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
339 }
340 }
343@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
344@Composable
345private fun ToolCallView(part: ToolPart, modifier: Modifier) {
346 val context = LocalContext.current
347 val haptics = LocalHapticFeedback.current
348 Column(
349 modifier = modifier
350 .fillMaxWidth()
351 .clip(MaterialTheme.shapes.small)
352 .background(MaterialTheme.colorScheme.surfaceVariant)
353 .padding(10.dp),
354 ) {
355 Row(verticalAlignment = Alignment.CenterVertically) {
356 ToolStatusIcon(part.state)
357 Text(
358 part.tool,
359 style = MaterialTheme.typography.labelLarge,
360 fontFamily = FontFamily.Monospace,
361 modifier = Modifier.padding(start = 6.dp),
362 )
363 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
364 // reports both start and end. Omitted while still running (no end yet) and when
365 // timing wasn't reported — a bare "· 0ms" would be noise. Placed inline with the
366 // tool name so a glance at the row tells both *what* and *how long*.
367 part.state.durationMs()?.let { ms ->
368 Text(
369 stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
370 style = MaterialTheme.typography.labelSmall,
371 color = MaterialTheme.colorScheme.onSurfaceVariant,
372 modifier = Modifier.padding(start = 6.dp),
373 )
374 }
375 }
376 // A human-readable summary of what the tool was asked to do (e.g. "Reading
377 // src/main.kt"), when the server provides one. Sits directly under the tool
378 // name so the user understands the call before diving into input/output.
379 part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
380 Text(
381 title,
382 style = MaterialTheme.typography.bodySmall,
383 color = MaterialTheme.colorScheme.onSurfaceVariant,
384 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
385 )
386 }
387 // A todowrite call carries the agent's task plan as its input; render it as a
388 // checklist rather than raw JSON so each step's progress reads at a glance.
389 val planTodos = remember(part) {
390 if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
391 }
392 if (planTodos.isNotEmpty()) {
393 TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
394 } else {
395 // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
396 // output so the call reads top-to-bottom: name → what → input → result.
397 part.state.inputElement()?.let { input ->
398 val inputLabel = stringResource(R.string.tool_input)
399 val pretty = remember(input) {
400 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
401 }
402 CollapsibleDetail(
403 label = inputLabel,
404 detail = pretty,
405 isDiff = false,
406 keySuffix = "input",
407 // The input block has a label row whose collapse toggle announces its state
408 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
409 // rather than an empty state (the defaults are "").
410 expandedState = stringResource(R.string.state_expanded),
411 collapsedState = stringResource(R.string.state_collapsed),
412 // Mirror the output block's copy affordance so a tool's arguments (e.g. a
413 // bash command or file-write content) can be copied for reuse without manual
414 // text selection.
415 onCopy = {
416 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
417 copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
418 },
419 )
420 }
421 }
422 val detail = when (val s = part.state) {
423 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
424 is ToolError -> s.error ?: stringResource(R.string.error_generic)
425 else -> null
426 }
427 if (detail != null) {
428 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
429 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
430 val isDiff = remember(detail) { looksLikeDiff(detail) }
431 val collapsed = remember(detail, isDiff) {
432 val head = detail.take(COLLAPSED_LIMIT)
433 // Truncating a diff mid-line makes DiffView render a malformed final line, so
434 // trim back to the last complete line when the content is a diff.
435 if (isDiff) head.substringBeforeLast('\n') else head
436 }
437 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
438 val expandedState = stringResource(R.string.state_expanded)
439 val collapsedState = stringResource(R.string.state_collapsed)
440 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
441 // How many lines are hidden while collapsed. detail.lines() splits the whole
442 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
443 // every recomposition, including each streaming update of a running tool.
444 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
445 // 0 (a single long line cut mid-way) falls back to the generic label.
446 val moreLines = remember(detail, collapsed) {
447 if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
448 }
449 CollapsibleDetail(
450 label = null,
451 detail = display,
452 isDiff = isDiff,
453 keySuffix = "output",
454 expanded = expanded || detail.length <= COLLAPSED_LIMIT,
455 onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
456 { expanded = !expanded }
457 } else null,
458 moreLines = moreLines,
459 expandedState = expandedState,
460 collapsedState = collapsedState,
461 onCopy = {
462 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
463 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
464 },
465 diffSaveKey = part.id,
466 )
467 }
468 }
471/**
472 * A collapsible monospace detail block shared by tool input and output. [label] is an
473 * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
474 * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
475 * with an expand/collapse affordance when [onToggleExpand] is supplied.
476 */
477@Composable
478private fun CollapsibleDetail(
479 label: String?,
480 detail: String,
481 isDiff: Boolean,
482 keySuffix: String,
483 expanded: Boolean = false,
484 onToggleExpand: (() -> Unit)? = null,
485 moreLines: Int = 0,
486 expandedState: String = "",
487 collapsedState: String = "",
488 onCopy: (() -> Unit)? = null,
489 diffSaveKey: String? = null,
490) {
491 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
492 if (label != null) {
493 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
494 Row(
495 modifier = Modifier
496 .fillMaxWidth()
497 .defaultMinSize(minHeight = 48.dp)
498 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
499 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
500 verticalAlignment = Alignment.CenterVertically,
501 ) {
502 Icon(
503 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
504 contentDescription = null,
505 modifier = Modifier.size(16.dp),
506 tint = MaterialTheme.colorScheme.onSurfaceVariant,
507 )
508 Text(
509 label,
510 style = MaterialTheme.typography.labelSmall,
511 color = MaterialTheme.colorScheme.onSurfaceVariant,
512 modifier = Modifier.padding(start = 4.dp),
513 )
514 }
515 if (!inputExpanded) return@Column
516 }
517 if (isDiff) {
518 DiffView(detail, saveKey = diffSaveKey)
519 } else {
520 // SelectionContainer so the user can select a portion of the output (e.g.
521 // a single line of stdout) instead of only copy-all via the button below.
522 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
523 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
524 SelectionContainer {
525 Text(
526 detail,
527 style = MaterialTheme.typography.bodySmall,
528 fontFamily = FontFamily.Monospace,
529 softWrap = false,
530 modifier = Modifier.horizontalScroll(rememberScrollState()),
531 )
532 }
533 }
534 if (onToggleExpand != null) {
535 TextButton(
536 onClick = onToggleExpand,
537 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
538 modifier = Modifier
539 .defaultMinSize(minHeight = 48.dp)
540 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
541 ) {
542 Icon(
543 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
544 contentDescription = null,
545 modifier = Modifier.size(16.dp),
546 )
547 Text(
548 when {
549 expanded -> stringResource(R.string.show_less)
550 moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
551 else -> stringResource(R.string.show_more)
552 },
553 style = MaterialTheme.typography.labelSmall,
554 modifier = Modifier.padding(start = 4.dp),
555 )
556 }
557 }
558 if (onCopy != null) {
559 TextButton(
560 onClick = onCopy,
561 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
562 modifier = Modifier
563 .defaultMinSize(minHeight = 48.dp)
564 .semantics(mergeDescendants = true) {},
565 ) {
566 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
567 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
568 }
569 }
570 }
573@Composable
574private fun ToolStatusIcon(state: ToolState) {
575 when (state) {
576 is ToolPending -> {
577 // Queued, not yet executing: a static clock icon distinguishes it from an
578 // actively-running tool (spinner) so the user can tell what's waiting.
579 val label = stringResource(R.string.tool_queued)
580 Icon(
581 Icons.Filled.Schedule,
582 contentDescription = label,
583 modifier = Modifier.size(16.dp),
584 tint = MaterialTheme.colorScheme.onSurfaceVariant,
585 )
586 }
587 is ToolRunning -> {
588 val label = stringResource(R.string.tool_running)
589 CircularProgressIndicator(
590 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
591 strokeWidth = 2.dp,
592 )
593 }
594 is ToolCompleted ->
595 Icon(
596 Icons.Filled.CheckCircle,
597 contentDescription = stringResource(R.string.tool_completed),
598 modifier = Modifier.size(16.dp),
599 tint = MaterialTheme.colorScheme.primary,
600 )
601 is ToolError ->
602 Icon(
603 Icons.Filled.Error,
604 contentDescription = stringResource(R.string.tool_error),
605 modifier = Modifier.size(16.dp),
606 tint = MaterialTheme.colorScheme.error,
607 )
608 is ToolUnknown ->
609 Icon(
610 Icons.Filled.Bolt,
611 contentDescription = stringResource(R.string.tool_unknown),
612 modifier = Modifier.size(16.dp),
613 )
614 }
617@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
618@Composable
619private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
620 val context = LocalContext.current
621 val haptics = LocalHapticFeedback.current
622 val path = part.sourcePath ?: part.url ?: part.filename
623 val copyLabel = stringResource(R.string.copy_path)
624 val openLabel = stringResource(R.string.open_file)
625 // When a source path is available and a navigation callback is wired, tapping the
626 // chip opens the file in the viewer (the action a user tapping a file reference
627 // most likely expects). Long-press still copies the path to the clipboard, so the
628 // copy affordance is preserved without being the default tap action. When no
629 // navigation is wired (e.g. tests), falls back to copy-path on tap.
630 val source = part.sourcePath
631 val opener = onOpenFile
632 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
633 val canOpen = openPath != null
634 val semanticsLabel = if (canOpen) openLabel else copyLabel
635 Row(
636 modifier = modifier
637 .clip(MaterialTheme.shapes.small)
638 .background(MaterialTheme.colorScheme.surfaceVariant)
639 .combinedClickable(
640 role = Role.Button,
641 onClick = {
642 val toOpen = openPath
643 if (toOpen != null) {
644 opener?.invoke(toOpen)
645 } else if (!path.isNullOrBlank()) {
646 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
647 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
648 showCopyToast(context, context.getString(R.string.path_copied))
649 }
650 },
651 onLongClick = {
652 if (!path.isNullOrBlank()) {
653 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
654 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
655 showCopyToast(context, context.getString(R.string.path_copied))
656 }
657 },
658 )
659 .semantics { contentDescription = semanticsLabel }
660 .defaultMinSize(minHeight = 48.dp)
661 .padding(horizontal = 12.dp, vertical = 6.dp),
662 verticalAlignment = Alignment.CenterVertically,
663 ) {
664 Icon(
665 Icons.Filled.Description,
666 contentDescription = null,
667 modifier = Modifier.size(16.dp),
668 tint = MaterialTheme.colorScheme.onSurfaceVariant,
669 )
670 Text(
671 part.filename ?: part.url ?: stringResource(R.string.file),
672 style = MaterialTheme.typography.bodyMedium,
673 modifier = Modifier.padding(start = 6.dp),
674 )
675 }