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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Android: every trigger type in the automations and agent trigger sheets.** A Local automation's Add trigger sheet and a persistent agent's New trigger sheet take all fourteen trigger types — GitLab, Jira, Pylon, PagerDuty, Sentry, Alertmanager and Datadog events included, with their event kinds, identity and filters — through the same rows as the New work form (one trigger editor in `core:ui`, `triggers/`). A Pylon / Alertmanager / Datadog trigger's own URL and shared secret are shown once, with copy buttons, right after it is created, in the sheets and in the New work form.
- **Every agent runtime gets the work's MCP servers.** Connections' tools and MCP servers reached only Claude Code (`.mcp.json`) and Codex (`config.toml`); a Job on Gemini, a persistent agent on Copilot, or a Task on OpenCode or Cursor had the connections' credentials and notes but no tools. Each runtime now gets the same servers in the file it reads, established from the versions the agent image installs: Gemini CLI in the user `settings.json` of a `GEMINI_CLI_HOME` of the run's own (the adapter's settings merged in, each server `trust: true`), OpenCode in an `OPENCODE_CONFIG` file merged before the project's own, GitHub Copilot CLI through `--additional-mcp-config @<file>` (`type: "local"`, every tool enabled), and Cursor in the project's `.cursor/mcp.json` with `--approve-mcps`. See `docs/connections.md` → "Which runtime reads which file".
- The per-run files live in one **run home**, `/home/agent/optio/runs/<run id>` (`OPTIO_RUN_HOME`), named after the task, Job run, or agent turn — so a retry lands in the same place — and removed when the run's script exits and again when the task's worktree is cleaned up. Codex's per-run `CODEX_HOME` moves there too; it used to be a random directory that was never removed from the repo pod's home volume.

### Changed

- **Android: work cards and session headers match iOS v0.15 and the web session sidebar.** Work, Overview and the Machines page draw one card (`core:workfeed`, `ui/WorkRowCard`): the harness logo with a status dot, the name and place, the host or pod and the recency, the status, the trigger and exit condition where they matter, and tappable ticket and PR badges (two at rest, up to eight expanded). The feed keeps a task's PR and the links its local terminal saw, shows each link once, and the Overview's Needs-you section no longer lists a local task twice. Local terminal and pod session headers show the harness, title, location, status, usage and cost together, with a full-width Chat / Terminal switch for local sessions and the link badges scrolling in a bounded area.
- Android trigger rows (automations, agents, the work form's summaries) read one dialect everywhere: `0 9 * * 1-5 · weekdays at 09:00 UTC`, `review requested, mentioned · @octocat · acme/web`, `firing · HighErrorRate · critical`.
- **iOS: real brand marks for PagerDuty, Pylon, Datadog, Alertmanager and AWS.** The four event sources left on SF Symbols (a bell, a life preserver, an ECG line, a dog) show the same marks as the web and Android apps everywhere a trigger is drawn — the New work form's Starts menu, scheduled Task, Job and agent trigger lists, Local automations — and `Brand(url:)` recognizes PagerDuty, Pylon, Datadog and Grafana / Alertmanager links. The Connections hub draws each catalog provider, the new-connection sheet and every connection row with the provider's brand (Notion, GitHub, Slack, Linear, Sentry, AWS, Pylon, PagerDuty) instead of a stand-in symbol; the generic providers (database, filesystem, terminal, HTTP API) keep theirs.

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package dev.optio.core.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.optio.core.model.WorkLink
import dev.optio.core.ui.theme.OptioTheme
import dev.optio.core.ui.theme.Spacing
import dev.optio.core.ui.theme.Tone
import dev.optio.core.ui.theme.medium
import dev.optio.core.ui.usage.AccountUsagePill
import dev.optio.core.ui.usage.ObservesUsage

/**
* The runtime's mark with a status dot at its bottom-right corner (iOS `GlyphView` + `StateDot`
* overlay; the web session rail's avatar). Shared by work cards and session headers. A plain
* shell draws the terminal glyph; the dot sits on a ring of [ring] (the card's colour) so it reads
* over the mark.
*/
@Composable
fun RuntimeMark(
runtime: String?,
tone: Tone,
modifier: Modifier = Modifier,
size: Dp = 36.dp,
ring: Color = OptioTheme.colors.card,
contentDescription: String? = null,
) {
Box(modifier.size(size)) {
AgentMark(
runtime = runtime,
size = size,
contentDescription = contentDescription,
tint = OptioTheme.colors.secondaryLabel,
)
Box(
Modifier
.align(Alignment.BottomEnd)
.offset(x = 3.dp, y = 3.dp)
.background(ring, CircleShape)
.padding(2.dp),
) {
StateDot(tone, size = 8.dp, pulse = false)
}
}
}

/**
* The identity of a session, above the conversation controls (iOS `SessionIdentityHeader`, the
* v0.15 session header): the runtime's mark with its status dot, the title and its place (a
* directory, a repo), then the status beside the usage pill, optional [facts] (`host · exit 0 ·
* $0.12`), an error [message] in the status tone, the PR / ticket badges (two at rest, at most
* eight expanded, scrolling within [LINKS_HEIGHT]), and [controls] (a Chat / Terminal switch).
* The links never compete with the controls for the same line.
*
* [showsUsage] adds the shared account usage pill and keeps the usage poller running while the
* header is on screen (it needs `LocalUsageStore`; without it the pill is simply absent).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SessionIdentityHeader(
title: String,
runtime: String?,
status: String,
tone: Tone,
location: String,
modifier: Modifier = Modifier,
facts: AnnotatedString? = null,
message: String? = null,
links: List<WorkLink> = emptyList(),
showsUsage: Boolean = false,
onOpenLink: ((String) -> Unit)? = null,
controls: @Composable () -> Unit = {},
) {
val colors = OptioTheme.colors
val type = OptioTheme.type
if (showsUsage) ObservesUsage()
Column(
modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.testTag("detail-header"),
) {
Column(
Modifier.fillMaxWidth().padding(horizontal = Spacing.l, vertical = Spacing.m),
verticalArrangement = Arrangement.spacedBy(Spacing.s),
) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(Spacing.m)) {
RuntimeMark(
runtime = runtime,
tone = tone,
size = 32.dp,
ring = MaterialTheme.colorScheme.surfaceContainerLow,
contentDescription = runtimeName(runtime),
modifier = Modifier.padding(top = 3.dp),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) {
Text(
title,
style = type.headline,
color = colors.label,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("session-title"),
)
SelectionContainer {
Text(
location,
style = type.monoCaption,
color = colors.secondaryLabel,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
}
FlowRow(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
itemVerticalAlignment = Alignment.CenterVertically,
) {
Text(
status,
style = type.subheadline.medium(),
color = tone.textColor,
modifier = Modifier.padding(end = Spacing.s).testTag("session-status"),
)
if (showsUsage) AccountUsagePill()
}
if (facts != null && facts.isNotEmpty()) {
Text(facts, style = type.caption, color = colors.secondaryLabel)
}
if (!message.isNullOrEmpty()) {
Text(message, style = type.caption, color = tone.textColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
SessionLinkBadges(links = links, expandedHeight = LINKS_HEIGHT, onOpen = onOpenLink, primaryTag = "work-link")
controls()
}
InsetDivider(start = 0.dp)
}
}

/** How tall the expanded badge list may grow in a header (iOS `expandedHeight: 160`). */
val LINKS_HEIGHT: Dp = 160.dp

/** The runtime's name for accessibility: "Claude Code", "terminal", … */
private fun runtimeName(runtime: String?): String {
AgentBrand.from(runtime)?.let { return it.label }
return when (runtime?.trim()?.lowercase()) {
null, "", "terminal", "shell" -> "terminal"
else -> runtime
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package dev.optio.core.ui.components

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.optio.core.model.WorkLink
import dev.optio.core.model.WorkLinkKind
import dev.optio.core.ui.theme.OptioTheme
import dev.optio.core.ui.theme.Radius
import dev.optio.core.ui.theme.Spacing
import dev.optio.core.ui.theme.medium
import dev.optio.core.ui.theme.mono

// The PR / ticket badges a piece of work wears (iOS `SessionLinkBadges`, web `work-links.tsx`):
// the same bounded, expandable list in work cards and session headers.

/** How many badges a resting card shows, and the most an expanded one shows. */
const val COLLAPSED_LINKS: Int = 2
const val EXPANDED_LINKS: Int = 8

/**
* `owner/repo#519` → `PR #519`; `group/proj!45` → `MR !45`; ticket refs (`ENG-12`) as they are.
* The repo is implied by where the work runs, so a badge carries only the number.
*/
fun shortLinkLabel(link: WorkLink): String {
val label = link.label
val i = label.indexOfLast { it == '#' || it == '!' }
if (i < 0) return label
val number = label.substring(i)
return when (link.kind) {
WorkLinkKind.PR -> (if (number.startsWith("!")) "MR " else "PR ") + number
else -> number
}
}

/** The links a collapsed / expanded badge list shows (iOS `visibleLinks`). */
fun visibleLinks(links: List<WorkLink>, expanded: Boolean): List<WorkLink> = links.take(if (expanded) EXPANDED_LINKS else COLLAPSED_LINKS)

/**
* The badges of [links]: two at rest, up to eight once expanded ("Show N more links"), the
* primary PR ([primaryPr]) drawn with its state's glyph colour. [expandedHeight] bounds the
* expanded grid in a scroll (session headers must leave room for the conversation). A tap opens
* the link through [onOpen] (the system browser by default). The primary PR's badge carries
* [primaryTag] so a list test can find "the PR link" as it always has.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SessionLinkBadges(
links: List<WorkLink>,
modifier: Modifier = Modifier,
primaryPr: String? = null,
prState: String? = null,
expandedHeight: Dp? = null,
onOpen: ((String) -> Unit)? = null,
primaryTag: String = "work-row-pr",
expandedInitially: Boolean = false,
) {
if (links.isEmpty()) return
val colors = OptioTheme.colors
val type = OptioTheme.type
val uriHandler = LocalUriHandler.current
val open: (String) -> Unit = onOpen ?: { url -> runCatching { uriHandler.openUri(url) } }
var expanded by rememberSaveable(links.size) { mutableStateOf(expandedInitially) }
val shown = visibleLinks(links, expanded)
Column(modifier.fillMaxWidth().animateContentSize(), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) {
val grid: @Composable () -> Unit = {
FlowRow(
Modifier.fillMaxWidth().testTag("session-links"),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
shown.forEach { link ->
val primary = primaryPr != null && link.url == primaryPr
LinkBadge(
link = link,
prState = if (primary) PrGlyphState.from(prState) else null,
onClick = { open(link.url) },
modifier = Modifier.testTag(if (primary) primaryTag else "work-link"),
)
}
}
}
if (expanded && expandedHeight != null) {
Column(Modifier.heightIn(max = expandedHeight).verticalScroll(rememberScrollState())) { grid() }
} else {
grid()
}
if (expanded && links.size > EXPANDED_LINKS) {
Text("Showing $EXPANDED_LINKS of ${links.size} links", style = type.caption, color = colors.tertiaryLabel)
}
if (links.size > COLLAPSED_LINKS) {
val more = minOf(links.size, EXPANDED_LINKS) - COLLAPSED_LINKS
Row(
Modifier
.clip(Radius.smallShape)
.clickable(role = Role.Button) { expanded = !expanded }
.padding(top = 4.dp, bottom = 4.dp, end = Spacing.xs)
.semantics { stateDescription = if (expanded) "Expanded" else "Collapsed" }
.testTag("session-links-disclosure"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(16.dp),
)
Text(
if (expanded) "Show fewer links" else "Show $more more links",
style = type.caption.medium(),
color = colors.accent,
)
}
}
}
}

/** One badge: the link's glyph (the PR glyph in its state's colour for the primary PR) and its short label. */
@Composable
private fun LinkBadge(
link: WorkLink,
prState: PrGlyphState?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = OptioTheme.colors
val what = if (link.kind == WorkLinkKind.PR) "Pull request" else "Ticket"
Row(
modifier
.clip(Radius.smallShape)
.background(colors.fillTertiary)
.border(1.dp, colors.separator, Radius.smallShape)
.clickable(role = Role.Button, onClickLabel = "Open ${link.label}", onClick = onClick)
.padding(horizontal = 8.dp, vertical = 6.dp)
.semantics(mergeDescendants = true) { contentDescription = "$what ${link.label}" },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
if (prState != null) {
PrGlyph(prState, size = 14.dp, contentDescription = null)
} else {
Icon(link.glyph, contentDescription = null, tint = colors.secondaryLabel, modifier = Modifier.size(14.dp))
}
Text(
shortLinkLabel(link),
style = OptioTheme.type.caption.mono(),
color = colors.secondaryLabel,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
Loading
Loading