diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css
index cf0aabc7a5..086dc40ee4 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.module.css
+++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css
@@ -101,13 +101,55 @@
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
-/* Decorative whale below the last flow row while the Session runs. */
-.runningWhale {
+.running {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ --dsh-chat-flow-gap: 12px;
+ color: var(--dsw-alias-label-deep-diving);
+ font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
+ line-height: calc(22px + var(--dsh-content-font-delta, 0px));
+}
+
+/* The last visible row before the status must contain output, not an input.
+ Hidden and empty seats do not separate a user message from the status. */
+.column > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
+ .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
+ [data-chat-group-key]
+) ~ .running > .runningDivider {
+ display: block;
+}
+
+.runningDivider {
+ display: none;
+ width: 100%;
+ height: 0.5px;
+ margin: 8px 0 10px;
+ background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
+ pointer-events: none;
+}
+
+.runningContent {
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+ gap: 6px;
+}
+
+.runningText {
+ min-width: 0;
+}
+
+.runningNumber {
+ font-family: var(--ds-font-family-code);
+ font-variant-numeric: tabular-nums;
+}
+
+.runningIcon {
display: inline-flex;
flex: none;
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
- color: var(--dsw-alias-label-deep-diving);
}
.openError {
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 3c9650da71..460f98b014 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -18,7 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx'
import { chatRenderKey } from './render-entry.ts'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { TurnNavigator } from './TurnNavigator.tsx'
-import { RunningWhaleTail } from './RunningWhaleTail.tsx'
+import { RunningStatus } from './RunningStatus.tsx'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useChatScroll } from './use-chat-scroll.ts'
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
@@ -112,6 +112,13 @@ export function ChatView({
// both the data and its change signal: the array identity moves only when a
// Turn enters, leaves, or changes its preview.
const turnNavigationItems = useChat(s => s.navigation.items())
+ const latestTurnAnchor = turnNavigationItems.at(-1)?.anchorKey
+ const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => {
+ const location = node?.location
+ return location?.kind === 'turn' || location?.kind === 'step'
+ ? location.turn.status === 'open' ? location.turn.start?.time : undefined
+ : undefined
+ })
// Host-computed whole-log outline; the merge is view-layer only (the
// conversation snapshot never carries projection values).
const turnOutline = useProjection('turnOutline')
@@ -270,7 +277,7 @@ export function ChatView({
t={t}
/>
- {running && }
+ {running && }
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
new file mode 100644
index 0000000000..c6b17921ed
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
@@ -0,0 +1,45 @@
+/** Running Turn clock isolated from the transcript's render cycle. */
+import { memo, useEffect, useState } from 'react'
+import { TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
+import { RunningWhaleTail } from './RunningWhaleTail.tsx'
+import a11yCss from './accessibility.module.css'
+import css from './ChatView.module.css'
+
+interface RunningStatusProps {
+ readonly startTime: number | undefined
+ readonly t: ChatViewSlotProps['t']
+}
+
+/**
+ * Show live elapsed time after the current Turn's content without announcing ticks.
+ * @param props - Current Turn start time and localized copy.
+ * @returns the blue running indicator; mount only while the Session is running.
+ */
+export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) {
+ const [now, setNow] = useState(Date.now)
+ useEffect(() => {
+ if (startTime === undefined) return
+ setNow(Date.now())
+ const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
+ return () => { clearInterval(timer) }
+ }, [startTime])
+ const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
+ duration: formatLiveRunDuration(Math.max(1000, now - startTime), t),
+ })
+ return (
+
+ {t('chat.deepDiving')}
+
+
+
+
+ {label.split(/(\d+)/).map((part, index) => (
+ {part}
+ ))}
+
+
+
+ )
+})
diff --git a/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx
index 23d4cbb4ad..19ecae8359 100644
--- a/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx
+++ b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx
@@ -52,7 +52,7 @@ export function RunningWhaleTail() {
return () => { preference.removeEventListener('change', update) }
}, [])
return (
-
+