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: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -701,12 +701,13 @@ jobs:
timeout-minutes: 5
run: pnpm --dir src/mobile-web run test:account-browser

- name: Test mobile web composer and rollback layouts
- name: Test mobile web conversation and input layouts
if: needs.build-impact.outputs.frontend_required != 'false'
timeout-minutes: 3
run: |
pnpm --dir src/mobile-web run test:prompt-suggestion-browser
pnpm --dir src/mobile-web run test:rollback-browser
pnpm --dir src/mobile-web run test:chat-transcript-browser

- name: Upload mobile input layout screenshots
if: always() && needs.build-impact.outputs.frontend_required != 'false'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ class ChatMessageBubbleTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun userAndAssistantTimesRenderBelowTheirMessages() {
var kind by mutableStateOf(ConversationRowKind.USER)
composeRule.setContent {
Bubble(row(kind, text = "Dated message").copy(timestampMs = 1700000000000L))
}
for (role in listOf(ConversationRowKind.USER, ConversationRowKind.ASSISTANT)) {
composeRule.runOnIdle { kind = role }
composeRule.onNodeWithTag("chat-message-timestamp").assertIsDisplayed()
val content = composeRule.onNodeWithText("Dated message").getUnclippedBoundsInRoot()
val time = composeRule.onNodeWithTag("chat-message-timestamp").getUnclippedBoundsInRoot()
assertTrue(time.top >= content.bottom)
}
}

@Test
fun aTurnThatWorkedInStepsIsDrawnInSteps() {
composeRule.setContent {
Expand Down Expand Up @@ -278,6 +293,7 @@ class ChatMessageBubbleTest {
showRetry = showRetry,
error = null,
live = false,
timestampMs = null,
)

private fun runningTool(): ToolCard = ToolCard(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ class ConversationViewTest {
showRetry = false,
error = null,
live = false,
timestampMs = null,
)

private fun string(resource: Int): String =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -89,6 +90,18 @@ internal fun ChatMessageBubble(
MessageImageGallery(images = row.images, userStyle = false)
}

row.timestampMs?.let { timestamp ->
val locale = LocalConfiguration.current.locales[0]
Text(
java.text.DateFormat.getDateTimeInstance(
java.text.DateFormat.SHORT, java.text.DateFormat.SHORT, locale,
).format(java.util.Date(timestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("chat-message-timestamp"),
)
}

// Two mutually exclusive footnotes about delivery: nothing has arrived
// yet, or the send was refused.
when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ internal fun ToolStatusIcon(tool: ToolCard, modifier: Modifier) {
modifier = Modifier
.size(18.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(5.dp))
.border(1.dp, badgeBorder(tool), RoundedCornerShape(5.dp)),
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(5.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
Expand All @@ -54,7 +54,7 @@ internal fun ToolStatusIcon(tool: ToolCard, modifier: Modifier) {
modifier = Modifier
.align(Alignment.BottomEnd)
.size(10.dp)
.background(statusTint(tool.phase), CircleShape)
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
.border(1.dp, MaterialTheme.colorScheme.surface, CircleShape),
contentAlignment = Alignment.Center,
) {
Expand Down Expand Up @@ -132,25 +132,9 @@ private fun kindTint(kind: ToolKind): Color = when (kind) {
else -> MaterialTheme.colorScheme.onSurfaceVariant
}

@Composable
private fun badgeBorder(tool: ToolCard): Color = if (tool.phase == ToolPhase.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.outlineVariant
}

private fun statusMark(phase: ToolPhase): String? = when (phase) {
ToolPhase.FAILED -> "!"
ToolPhase.PENDING_CONFIRMATION -> "?"
ToolPhase.RUNNING -> "•"
ToolPhase.CANCELLED -> "×"
// Done and queued are the states nobody is waiting on.
ToolPhase.COMPLETED, ToolPhase.WAITING -> null
}

@Composable
private fun statusTint(phase: ToolPhase): Color = if (phase == ToolPhase.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
ToolPhase.COMPLETED, ToolPhase.FAILED, ToolPhase.WAITING -> null
}
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ internal fun ToolStatusRow(
(if (ToolAction.ANSWER in tool.actions) setOf(ToolAction.CANCEL) else emptySet())
} else tool.actions
val blocking = transcriptActions.isNotEmpty()
val emphasized = expanded || blocking || tool.phase == ToolPhase.FAILED
val emphasized = expanded || blocking
val canExpand = tool.expandable
val openable = tool.filePath.isNotEmpty()

Expand Down Expand Up @@ -268,7 +268,6 @@ internal fun ToolStatusRow(
toolLineLabel(tool),
style = MaterialTheme.typography.bodySmall,
color = when {
tool.phase == ToolPhase.FAILED -> MaterialTheme.colorScheme.error
openable -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
Expand Down Expand Up @@ -442,8 +441,8 @@ private fun TodoToolCard(tool: ToolCard, enabled: Boolean, modifier: Modifier) {
* The row's one line, ported from `toolLineLabel`.
*
* What it leads with is what the reader wants first, and that changes with the
* state: a running tool leads with the thing it is working on, a failed one with
* the failure, and anything waiting on the user with the fact that it is.
* state: a running tool leads with the thing it is working on, and anything
* waiting on the user with the fact that it is. Failures remain in the details.
*/
@Composable
private fun toolLineLabel(tool: ToolCard): String {
Expand All @@ -453,9 +452,6 @@ private fun toolLineLabel(tool: ToolCard): String {
tool.phase == ToolPhase.RUNNING ->
stringResource(R.string.tool_line_running, target.ifEmpty { operation })

tool.phase == ToolPhase.FAILED ->
join(stringResource(R.string.tool_line_failed, operation), target)

tool.phase == ToolPhase.PENDING_CONFIRMATION || ToolAction.ANSWER in tool.actions ->
join(stringResource(R.string.tool_phase_pending), operation)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ private fun SidebarActiveDeviceBody(
modesOnly = true,
path = path,
expanded = createMenuOpen,
onToggle = { createMenuOpen = !createMenuOpen },
onToggle = {
if (entry.kind == "assistant") onCreateInWorkspace(entry, "claw")
else createMenuOpen = !createMenuOpen
},
onDismiss = { createMenuOpen = false },
onCreateAgent = { agent -> createMenuOpen = false; onCreateInWorkspace(entry, agent) },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@
},
"message_bubble": {
"purpose": "Presents one user or assistant message in the conversation timeline.",
"anatomy": ["message_text", "role_surface", "hairline_border"],
"anatomy": ["message_text", "role_surface", "hairline_border", "message_timestamp"],
"states": ["user", "assistant"],
"tokens": ["body_large", "message_bubble_max_width", "message_bubble_horizontal_padding", "message_bubble_vertical_padding", "message_bubble_radius", "message_spacing", "timeline_top_padding"],
"platformNotes": "The maximum width is the 276-unit compact HarmonyOS baseline; wide layouts may use a separate responsive policy."
"platformNotes": "The maximum width is the 276-unit compact HarmonyOS baseline; wide layouts may use a separate responsive policy. Show each available host message time below its content in localized short date/time with muted label-small text, right aligned for users and left aligned for assistants. Omit unavailable or invalid legacy timestamps. Ordinary failed tool calls share the neutral collapsed activity presentation; their actual failure details remain expandable. Questions, approvals and failed replies retain their attention states."
},
"conversation_overlay": {
"purpose": "Lets the transcript own the whole pane while the header and composer stay reachable above it.",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ export class RemoteI18n {
return [];
}

static formatMessageTime(timestamp: number): string {
const locale = RemoteI18n.language() === APP_LOCALE_EN_US ? 'en-US' : 'zh-CN';
return new intl.DateTimeFormat(locale, {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
}).format(new Date(timestamp));
}

static formatAbsoluteDate(timestamp: number): string {
const locale = RemoteI18n.language() === APP_LOCALE_EN_US ? 'en-US' : 'zh-CN';
const formatter = new intl.DateTimeFormat(locale, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MessageBodyMarkdown, MessageFileCards, MessageImageGallery, MessageMark
import {
AssistantMessageCopyAction,
ChatMessageRetryAction,
ChatMessageTimestamp,
ChatTypingDots,
ChatUserMessageBubble
} from './ChatMessageChrome';
Expand Down Expand Up @@ -156,6 +157,7 @@ export struct ChatMessageBubble {
this.showRetryAction ? (this.item.detail || '') : ''
)
}
ChatMessageTimestamp({ timestamp: this.item.timestamp || '' })
this.ActionRow()
}
.layoutWeight(1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TimeFormat } from '../../services/TimeFormat';
import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens';
import { RemoteI18n } from '../../i18n/RemoteI18n';
import { MessageCopyScope } from '../actions/ConversationIntent';
Expand Down Expand Up @@ -155,6 +156,7 @@ export struct ChatUserMessageBubble {
.borderRadius(MobileDesignGeometry.messageBubbleRadius)
.alignItems(HorizontalAlign.Start)
}
ChatMessageTimestamp({ timestamp: this.item.timestamp || '' })
if (this.item.status === 'failed' && this.showRetryAction) {
ChatMessageRetryAction({ retryText: this.item.text, onRetry: this.onRetryMessage })
}
Expand Down Expand Up @@ -349,3 +351,17 @@ export struct AssistantMessageCopyAction {
return scope === MessageCopyScope.Plain ? MarkdownParser.formatInlineText(this.source) : this.source;
}
}

@ComponentV2
export struct ChatMessageTimestamp {
@Param timestamp: string = '';

build() {
if (TimeFormat.message(this.timestamp).length > 0) {
Text(TimeFormat.message(this.timestamp))
.fontSize(MobileDesignTypography.labelSmall.size)
.fontColor(MUTED)
.id('chat-message-timestamp')
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,12 @@ struct SidebarWorkspaceGroup {
}
})
.onClick(() => {
this.showCreateMenu = !this.showCreateMenu;
if (this.workspaceKind === 'assistant') {
this.showCreateMenu = false;
this.onCreateInWorkspace(this.path, 'claw', this.connectionId, this.sshHost, this.workspaceIdOrUndefined());
} else {
this.showCreateMenu = !this.showCreateMenu;
}
})
}
.width('100%')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,7 @@ export struct ToolStatusList {
this.ToolStatusIcon(tool)
Text(this.toolLineLabel(tool))
.fontSize(MobileDesignTypography.bodySmall.size)
.fontColor(this.hasToolError(tool) ? STATUS_DANGER :
(this.toolFilePath(tool).length > 0 ? FILE_LINK : MUTED))
.fontColor(this.toolFilePath(tool).length > 0 ? FILE_LINK : MUTED)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Expand Down Expand Up @@ -502,7 +501,6 @@ export struct ToolStatusList {

private isEmphasizedToolRow(tool: ConversationUiToolStatus, index: number): boolean {
return this.expandedToolKey === this.toolKey(tool, index) ||
this.hasToolError(tool) ||
this.isPendingConfirmation(tool) ||
this.isQuestionTool(tool) ||
this.isRunningTool(tool);
Expand Down Expand Up @@ -852,14 +850,10 @@ export struct ToolStatusList {
if (this.isRunningTool(tool)) {
return `${RemoteI18n.t('chat.toolRunning')} “${this.compactText(this.operationTarget(tool), 24)}”`;
}
if (this.hasToolError(tool)) {
return `${RemoteI18n.f('chat.toolFailed', this.operationLabel(tool))} · ` +
`${this.compactText(this.operationTarget(tool), 24)}`;
}
if (this.isPendingConfirmation(tool) || this.isQuestionTool(tool)) {
return `${this.displayStatus(tool.status || 'pending')} · ${this.operationLabel(tool)}`;
}
if (this.isCompletedTool(tool) || this.isCancelledTool(tool)) {
if (this.isCompletedTool(tool) || this.isCancelledTool(tool) || this.hasToolError(tool)) {
return `${this.operationLabel(tool)} · ${this.compactText(this.operationTarget(tool), 28)}`;
}
return `${this.displayStatus(tool.status || 'pending')} · ${this.operationLabel(tool)}`;
Expand Down Expand Up @@ -915,16 +909,10 @@ export struct ToolStatusList {
}

private toolTypeBorderColor(tool: ConversationUiToolStatus): ResourceColor {
if (this.hasToolError(tool)) {
return STATUS_DANGER;
}
return LINE;
}

private toolStatusIcon(tool: ConversationUiToolStatus): string {
if (this.hasToolError(tool)) {
return '!';
}
const normalized = (tool.status || '').toLowerCase();
if (normalized === 'running' || normalized === 'active') {
return '•';
Expand All @@ -939,15 +927,11 @@ export struct ToolStatusList {
}

private toolStatusColor(tool: ConversationUiToolStatus): ResourceColor {
if (this.hasToolError(tool)) {
return STATUS_DANGER;
}
return MUTED;
}

private shouldShowStatusBadge(tool: ConversationUiToolStatus): boolean {
return this.hasToolError(tool) ||
this.isPendingConfirmation(tool) ||
return this.isPendingConfirmation(tool) ||
this.isQuestionTool(tool) ||
this.isRunningTool(tool) ||
this.isCancelledTool(tool);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ export class ToolCollapsePolicy {
static readonly MIN_SUMMARY_COUNT: number = 2;

static shouldFoldIntoSummary(tool: ConversationUiToolStatus): boolean {
if (PlanToolPolicy.isPlanTool(tool) || ToolCollapsePolicy.hasError(tool) ||
if (PlanToolPolicy.isPlanTool(tool) ||
ToolCollapsePolicy.isPending(tool) ||
ToolCollapsePolicy.isQuestion(tool) ||
ToolCollapsePolicy.isRunning(tool)) {
return false;
}
return ToolCollapsePolicy.isCompleted(tool) || ToolCollapsePolicy.isCancelled(tool);
return ToolCollapsePolicy.isCompleted(tool) || ToolCollapsePolicy.isCancelled(tool) ||
ToolCollapsePolicy.hasError(tool);
}

static collapsedGroups(tools: ConversationUiToolStatus[]): ToolCollapseGroup[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export class DurableSessionReducer {
const metadata = turn.userMessage.metadata as UserMetadata | undefined;
const userText = metadata?.original_text || turn.userMessage.content;
if (userText.length > 0 || (metadata?.images?.length || 0) > 0) result.push({ id: turn.userMessage.id, turnId: turn.turnId,
role: 'user', text: userText, images: metadata?.images, status: 'sent', timestamp: new Date(turn.userMessage.timestamp).toISOString() });
role: 'user', text: userText, images: metadata?.images, status: 'sent', timestamp: String(turn.userMessage.timestamp) });
const turnFence = this.tombstones.get(`turn/${turn.turnId}`) ?? 0;
const visible = entries.filter((item: StoredItem) => {
const round = this.rounds.get(item.roundId);
Expand Down Expand Up @@ -201,7 +201,7 @@ export class DurableSessionReducer {
result.push({ id: `assistant-${turn.turnId}`, turnId: turn.turnId, role: 'assistant', status: status,
text: items.filter((item: ChatMessageItemResponse) => item.type === 'text' && !item.is_subagent).map((item: ChatMessageItemResponse) => item.content || '').join(''),
thinking: items.filter((item: ChatMessageItemResponse) => item.type === 'thinking' && !item.is_subagent).map((item: ChatMessageItemResponse) => item.content || '').join(''),
tools: tools, items: items, timestamp: new Date(turn.timestamp).toISOString(), error: turn.error, renderVersion: stored.revision });
tools: tools, items: items, timestamp: String(turn.timestamp), error: turn.error, renderVersion: stored.revision });
return result;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { RemoteI18n } from '../i18n/RemoteI18n';

export class TimeFormat {
static message(value: string): string {
const timestamp = TimeFormat.timestampMs(value);
return Number.isFinite(new Date(timestamp).getTime()) ? RemoteI18n.formatMessageTime(timestamp) : '';
}

static relative(value: string, nowMs: number = Date.now()): string {
const timestamp = TimeFormat.timestampMs(value);
if (Number.isNaN(timestamp)) {
Expand Down
Loading
Loading