Skip to content
Open
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 @@ -27,6 +27,8 @@ import { ProviderIcon } from './ProviderIcon';
const STATUS_CONFIG: Record<SessionState, { color: string; dotColor: string; isPulsing: boolean; isConnected: boolean }> = {
disconnected: { color: '#999', dotColor: '#999', isPulsing: false, isConnected: false },
thinking: { color: '#007AFF', dotColor: '#007AFF', isPulsing: true, isConnected: true },
// Lighter blue than `thinking`: still working, but not on your turn.
background: { color: '#5AC8FA', dotColor: '#5AC8FA', isPulsing: true, isConnected: true },
waiting: { color: '#34C759', dotColor: '#34C759', isPulsing: false, isConnected: true },
permission_required: { color: '#FF9500', dotColor: '#FF9500', isPulsing: true, isConnected: true },
};
Expand Down Expand Up @@ -281,7 +283,7 @@ export const CompactSessionRow = React.memo(({ session, selected, showBorder }:
color={theme.colors.textSecondary}
/>
);
} else if (session.state === 'permission_required' || session.state === 'thinking') {
} else if (session.state === 'permission_required' || session.state === 'thinking' || session.state === 'background') {
indicator = <StatusDot color={status.dotColor} isPulsing={status.isPulsing} />;
} else if (session.state === 'waiting') {
indicator = <StatusDot color={theme.colors.textSecondary} isPulsing={false} />;
Expand Down
6 changes: 5 additions & 1 deletion packages/happy-app/sources/components/SessionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ export function SessionsList({
const STATUS_CONFIG: Record<SessionState, { color: string; dotColor: string; isPulsing: boolean; isConnected: boolean }> = {
disconnected: { color: '#999', dotColor: '#999', isPulsing: false, isConnected: false },
thinking: { color: '#007AFF', dotColor: '#007AFF', isPulsing: true, isConnected: true },
// Lighter blue than `thinking`: still working, but not on your turn.
background: { color: '#5AC8FA', dotColor: '#5AC8FA', isPulsing: true, isConnected: true },
waiting: { color: '#34C759', dotColor: '#34C759', isPulsing: false, isConnected: true },
permission_required: { color: '#FF9500', dotColor: '#FF9500', isPulsing: true, isConnected: true },
};
Expand Down Expand Up @@ -449,7 +451,9 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
? t('status.lastSeen', { time: formatLastSeen(session.activeAt!, false) })
: session.state === 'permission_required'
? t('status.permissionRequired')
: t('status.online');
: session.state === 'background'
? t('status.backgroundWork', { count: session.backgroundCount })
: t('status.online');

const handlePress = React.useCallback(() => {
navigateToSession(session.id);
Expand Down
39 changes: 38 additions & 1 deletion packages/happy-app/sources/sync/rig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,14 @@ export function rigCanUseShell(metadata: Metadata | null | undefined): boolean {
|| (metadata?.capabilities?.shell === true && rigHasRpcMethod(metadata, 'bash'));
}

/**
* Activity indicators for any client that reports `metadata.activity` — Rig
* reports it natively, and happy-cli derives it from Claude Code's Stop hook.
* Gating this on Rig would hide background shells and subagents for every
* Claude session, which is where most of that work actually happens.
*/
export function getRigActivityIndicators(metadata: Metadata | null | undefined): RigActivityIndicator[] {
if (!isRigMetadata(metadata) || !metadata?.activity) return [];
if (!metadata?.activity) return [];
const indicators: RigActivityIndicator[] = [];
const { activity } = metadata;
if (activity.subagents.running > 0 || activity.subagents.queued > 0) {
Expand All @@ -198,6 +204,37 @@ export function getRigActivityIndicators(metadata: Metadata | null | undefined):
return indicators;
}

/**
* Whether the session has background work in flight. Cheaper than building the
* indicator list, and used on the session-list hot path to decide whether an
* idle session should still read as busy.
*/
export function hasBackgroundActivity(metadata: Metadata | null | undefined): boolean {
const activity = metadata?.activity;
if (!activity) return false;
return activity.subagents.running > 0
|| activity.subagents.queued > 0
|| activity.workflows.running > 0
|| activity.processes.running > 0
|| activity.tasks.pending > 0
|| activity.tasks.inProgress > 0;
}

/**
* Total in-flight background items, for the one-line "N running" status text.
* Queued work counts too — it is why the session is still busy.
*/
export function backgroundWorkCount(metadata: Metadata | null | undefined): number {
const activity = metadata?.activity;
if (!activity) return 0;
return activity.subagents.running
+ activity.subagents.queued
+ activity.workflows.running
+ activity.processes.running
+ activity.tasks.pending
+ activity.tasks.inProgress;
}

export function getRigReasoningLevels(metadata: Metadata | null | undefined, modelKey: string | null | undefined): string[] {
if (!isRigMetadata(metadata)) return [];
const model = getRigModels(metadata).find((candidate) => candidate.key === modelKey);
Expand Down
9 changes: 8 additions & 1 deletion packages/happy-app/sources/sync/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { getCurrentRealtimeSessionId, getVoiceSession } from '@/realtime/Realtim
import { isMutableTool } from "@/components/tools/knownTools";
import { DecryptedArtifact } from "./artifactTypes";
import { FeedItem } from "./feedTypes";
import { getRigActivityIndicators, getRigIdentity, isRigMetadata } from './rig';
import { backgroundWorkCount, getRigActivityIndicators, getRigIdentity, hasBackgroundActivity, isRigMetadata } from './rig';
import { indexSessionsById } from './sessionIdentity';

// Debounce timer for realtimeMode changes
Expand Down Expand Up @@ -91,6 +91,8 @@ export interface SessionRowData {
providerKind: string | null;
modelName: string | null;
activitySummary: string | null;
/** In-flight background items, for the "N running in background" status line. */
backgroundCount: number;
state: SessionState;
// Only present on inactive sessions — active sessions never show "last seen"
// and activeAt updates on every heartbeat, causing needless deep-equal diffs
Expand Down Expand Up @@ -125,6 +127,10 @@ function buildSessionRowData(session: Session, unreadSessionIds?: Set<string>):
state = 'permission_required';
} else if (session.thinking) {
state = 'thinking';
} else if (hasBackgroundActivity(session.metadata)) {
// The turn ended but a background shell / subagent / workflow is still
// running, so the session is not actually idle.
state = 'background';
} else {
state = 'waiting';
}
Expand All @@ -144,6 +150,7 @@ function buildSessionRowData(session: Session, unreadSessionIds?: Set<string>):
activitySummary: rigActivity.length > 0
? rigActivity.map((item) => `${item.count}${item.queued ? `+${item.queued}` : ''} ${item.key}`).join(' · ')
: null,
backgroundCount: backgroundWorkCount(session.metadata),
state,
...(!session.active && { activeAt: session.activeAt, createdAt: session.createdAt }),
hasDraft: !!session.draft,
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/_default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const en = {
activeNow: 'Active now',
unknown: 'unknown',
unread: 'new results',
backgroundWork: ({ count }: { count: number }) => `${count} running in background`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const ca: TranslationStructure = {
activeNow: 'Actiu ara',
unknown: 'desconegut',
unread: 'nous resultats',
backgroundWork: ({ count }: { count: number }) => `${count} en segon pla`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const en: TranslationStructure = {
activeNow: 'Active now',
unknown: 'unknown',
unread: 'new results',
backgroundWork: ({ count }: { count: number }) => `${count} running in background`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const es: TranslationStructure = {
activeNow: 'Activo ahora',
unknown: 'desconocido',
unread: 'nuevos resultados',
backgroundWork: ({ count }: { count: number }) => `${count} en segundo plano`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const it: TranslationStructure = {
activeNow: 'Attivo ora',
unknown: 'sconosciuto',
unread: 'nuovi risultati',
backgroundWork: ({ count }: { count: number }) => `${count} in background`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const ja: TranslationStructure = {
activeNow: 'アクティブ',
unknown: '不明',
unread: '新しい結果',
backgroundWork: ({ count }: { count: number }) => `バックグラウンドで${count}件実行中`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const pl: TranslationStructure = {
activeNow: 'Aktywny teraz',
unknown: 'nieznane',
unread: 'nowe wyniki',
backgroundWork: ({ count }: { count: number }) => `${count} w tle`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const pt: TranslationStructure = {
activeNow: 'Ativo agora',
unknown: 'desconhecido',
unread: 'novos resultados',
backgroundWork: ({ count }: { count: number }) => `${count} em segundo plano`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ export const ru: TranslationStructure = {
activeNow: 'Активен сейчас',
unknown: 'неизвестно',
unread: 'новые результаты',
backgroundWork: ({ count }: { count: number }) => `${count} в фоне`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const zhHans: TranslationStructure = {
activeNow: '当前活跃',
unknown: '未知',
unread: '新结果',
backgroundWork: ({ count }: { count: number }) => `后台运行 ${count} 项`,
},

time: {
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const zhHant: TranslationStructure = {
activeNow: '目前活躍',
unknown: '未知',
unread: '新結果',
backgroundWork: ({ count }: { count: number }) => `背景執行 ${count} 項`,
},

time: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function session(
providerKind: null,
modelName: null,
activitySummary: null,
backgroundCount: 0,
state: 'waiting',
createdAt,
hasDraft: false,
Expand Down
23 changes: 22 additions & 1 deletion packages/happy-app/sources/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import * as React from 'react';
import { Session } from '@/sync/storageTypes';
import { t } from '@/text';
import { buildResumeCommand, buildResumeCommandBlock, ResumeCommandBlock } from './resumeCommand';
import { backgroundWorkCount, hasBackgroundActivity } from '@/sync/rig';

export type SessionState = 'disconnected' | 'thinking' | 'waiting' | 'permission_required';
/**
* `background` means the turn is over but the session still has work in flight
* — a background shell, a subagent, a workflow. It sits between `thinking` and
* `waiting`: nothing is being generated, yet the session is not done either.
*/
export type SessionState = 'disconnected' | 'thinking' | 'background' | 'waiting' | 'permission_required';

export interface SessionStatus {
state: SessionState;
Expand Down Expand Up @@ -63,6 +69,21 @@ export function useSessionStatus(session: Session): SessionStatus {
};
}

// Turn is over, but a background shell / subagent / workflow is still
// running — a lighter blue than `thinking` to read as "related activity,
// not the main thread".
if (hasBackgroundActivity(session.metadata)) {
return {
state: 'background',
isConnected: true,
statusText: t('status.backgroundWork', { count: backgroundWorkCount(session.metadata) }),
shouldShowStatus: true,
statusColor: '#5AC8FA',
statusDotColor: '#5AC8FA',
isPulsing: true
};
}

return {
state: 'waiting',
isConnected: true,
Expand Down
18 changes: 13 additions & 5 deletions packages/happy-cli/scripts/session_hook_forwarder.cjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
#!/usr/bin/env node
/**
* Session Hook Forwarder
*
* This script is executed by Claude's SessionStart hook.
*
* This script is executed by Claude's SessionStart / Stop / SessionEnd hooks.
* It reads JSON data from stdin and forwards it to Happy's hook server.
*
* Usage: echo '{"session_id":"..."}' | node session_hook_forwarder.cjs <port>
*
* Usage: echo '{"session_id":"..."}' | node session_hook_forwarder.cjs <port> [path]
*
* `path` defaults to /hook/session-start so existing SessionStart hook settings
* files written by an older happy-cli keep working after an upgrade.
*/

const http = require('http');

const port = parseInt(process.argv[2], 10);
// Only the paths this forwarder is allowed to target — a settings file is
// user-editable, and this script is spawned by Claude on every hook.
const ALLOWED_PATHS = ['/hook/session-start', '/hook/stop', '/hook/session-end'];
const requestedPath = process.argv[3] || '/hook/session-start';
const path = ALLOWED_PATHS.includes(requestedPath) ? requestedPath : '/hook/session-start';

if (!port || isNaN(port)) {
process.exit(1);
Expand All @@ -29,7 +37,7 @@ process.stdin.on('end', () => {
host: '127.0.0.1',
port: port,
method: 'POST',
path: '/hook/session-start',
path: path,
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length
Expand Down
8 changes: 8 additions & 0 deletions packages/happy-cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod'
import type { Update, UpdateMachineBody } from '@slopus/happy-wire';
import { UsageSchema } from '@/claude/types'
import type { SandboxConfig } from '@/persistence'
import type { SessionActivity } from '@/claude/utils/backgroundActivity'

export {
SessionMessageContentSchema,
Expand Down Expand Up @@ -331,6 +332,13 @@ export type Metadata = {
* inside the parent session's sidebar panel.
*/
isSideChat?: boolean
/**
* In-flight background work for this session (background shells, subagents,
* workflows), refreshed from Claude Code's Stop hook. Lets the app show that
* a session is idle-but-still-busy, which `thinking` cannot express because
* it only describes the current turn.
*/
activity?: SessionActivity
};

export type UsageLimitWindowStatus = 'allowed' | 'allowed_warning' | 'rejected'
Expand Down
17 changes: 17 additions & 0 deletions packages/happy-cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { initialMachineMetadata } from '@/daemon/run';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { startHookServer } from '@/claude/utils/startHookServer';
import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings';
import { activityEquals, isActivityEmpty, summarizeBackgroundTasks, type SessionActivity } from '@/claude/utils/backgroundActivity';
import { registerKillSessionHandler } from './registerKillSessionHandler';
import { projectPath } from '../projectPath';
import { resolve } from 'node:path';
Expand Down Expand Up @@ -466,6 +467,11 @@ export async function runClaude(credentials: Credentials, options: StartOptions
// Used by hook server to notify Session when Claude changes session ID
let currentSession: Session | null = null;

// Last background-activity summary pushed to metadata. The Stop hook fires
// on every turn, and most turns start no background work at all, so this
// keeps idle turns from re-sending an identical metadata update.
let lastReportedActivity: SessionActivity | undefined = undefined;

// Start Hook server for receiving Claude session notifications
const hookServer = await startHookServer({
onSessionHook: (sessionId, data) => {
Expand Down Expand Up @@ -494,6 +500,17 @@ export async function runClaude(credentials: Credentials, options: StartOptions
currentSession.onSessionFound(sessionId);
}
}
},
onBackgroundActivity: (tasks) => {
const activity = summarizeBackgroundTasks(tasks);
if (activityEquals(activity, lastReportedActivity)) {
return;
}
lastReportedActivity = activity;
// Absent rather than an all-zero object once nothing is running, so
// a session with no background work carries no activity field at all.
const next = isActivityEmpty(activity) ? undefined : activity;
session.updateMetadata((metadata) => ({ ...metadata, activity: next }));
}
});
logger.debug(`[START] Hook server started on port ${hookServer.port}`);
Expand Down
Loading
Loading