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
7 changes: 5 additions & 2 deletions packages/happy-app/sources/-session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { voiceHooks } from '@/realtime/hooks/voiceHooks';
import { startRealtimeSession, stopRealtimeSession } from '@/realtime/RealtimeSession';
import { gitStatusSync } from '@/sync/gitStatusSync';
import { sessionAbort } from '@/sync/ops';
import { storage, useIsDataReady, useLocalSetting, useRealtimeStatus, useSessionMessages, useSessionUsage, useSetting } from '@/sync/storage';
import { storage, useIsDataReady, useLocalSetting, useMachine, useRealtimeStatus, useSessionMessages, useSessionUsage, useSetting } from '@/sync/storage';
import { useSession } from '@/sync/storage';
import { Session } from '@/sync/storageTypes';
import { sync } from '@/sync/sync';
Expand Down Expand Up @@ -204,6 +204,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
// Check if CLI version is outdated and not already acknowledged
const cliVersion = session.metadata?.version;
const machineId = session.metadata?.machineId;
const machine = useMachine(machineId ?? '');
const isCliOutdated = cliVersion && !isVersionSupported(cliVersion, MINIMUM_CLI_VERSION);
const isAcknowledged = machineId && acknowledgedCliVersions[machineId] === cliVersion;
const shouldShowCliWarning = isCliOutdated && !isAcknowledged;
Expand Down Expand Up @@ -238,7 +239,9 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
const isArchivedSession = session.metadata?.lifecycleState === 'archived';
const isDisconnected = !sessionStatus.isConnected;
const isInactiveArchivedSession = isArchivedSession && isDisconnected;
const resumeCommandBlock = getResumeCommandBlock(session);
const resumeCommandBlock = getResumeCommandBlock(session, {
preferHappyResume: !!machine?.metadata?.resumeSupport?.happyAgentAuthenticated,
});

// Use draft hook for auto-saving message drafts
const { clearDraft } = useDraft(sessionId, message, setMessage);
Expand Down
17 changes: 11 additions & 6 deletions packages/happy-app/sources/app/(app)/session/[id]/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Item } from '@/components/Item';
import { ItemGroup } from '@/components/ItemGroup';
import { ItemList } from '@/components/ItemList';
import { Avatar } from '@/components/Avatar';
import { useSession, useIsDataReady } from '@/sync/storage';
import { useSession, useIsDataReady, useMachine } from '@/sync/storage';
import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId, getResumeCommand } from '@/utils/sessionUtils';
import * as Clipboard from 'expo-clipboard';
import { Modal } from '@/modal';
Expand Down Expand Up @@ -134,6 +134,11 @@ function SessionInfoContent({ session }: { session: Session }) {
resumeSession,
resumeSessionSubtitle,
} = useSessionQuickActions(session);
const machine = useMachine(session.metadata?.machineId ?? '');
const prefersHappyResume = !!machine?.metadata?.resumeSupport?.happyAgentAuthenticated;
const resumeCommand = !sessionStatus.isConnected
? (prefersHappyResume ? `happy resume ${session.id}` : getResumeCommand(session))
: null;

// Check if CLI version is outdated
const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION);
Expand All @@ -154,7 +159,7 @@ function SessionInfoContent({ session }: { session: Session }) {

// Use HappyAction for archiving - it handles errors automatically
const [archivingSession, performArchive] = useHappyAction(async () => {
const result = await sessionKill(session.id);
const result = await sessionKill(session.id, session.metadata?.machineId);
if (!result.success) {
throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false);
}
Expand All @@ -175,7 +180,7 @@ function SessionInfoContent({ session }: { session: Session }) {

// Kill session first if it's still active (best-effort)
if (sessionStatus.isConnected || session.active) {
await sessionKill(session.id).catch(() => {});
await sessionKill(session.id, session.metadata?.machineId).catch(() => {});
}

// Clean up worktree if this session was in one (best-effort)
Expand Down Expand Up @@ -305,12 +310,12 @@ function SessionInfoContent({ session }: { session: Session }) {
)}
{/* Resume command — shown for disconnected sessions with a backend session ID */}
{/* TODO: migrate to `happy resume <happy-session-id>` once it works without happy-agent auth */}
{!sessionStatus.isConnected && getResumeCommand(session) && (
{!sessionStatus.isConnected && resumeCommand && (
<CopyableItem
title="Resume Command"
subtitle={getResumeCommand(session)!}
subtitle={resumeCommand}
icon={<Ionicons name="play-circle-outline" size={29} color="#30D158" />}
copyText={getResumeCommand(session)!}
copyText={resumeCommand}
/>
)}
<Item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
const [actionsAnchor, setActionsAnchor] = React.useState<SessionActionsAnchor | null>(null);

const [archivingSession, performArchive] = useHappyAction(async () => {
const result = await sessionKill(session.id);
const result = await sessionKill(session.id, session.metadata?.machineId);
if (!result.success) {
throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
const flavorIcon = flavorIcons[flavor as keyof typeof flavorIcons] || flavorIcons.claude;

const [archivingSession, performArchive] = useHappyAction(async () => {
const result = await sessionKill(session.id);
const result = await sessionKill(session.id, session.metadata?.machineId);
if (!result.success) {
throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/happy-app/sources/hooks/useSessionQuickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function useSessionQuickActions(
});

const [archivingSession, performArchive] = useHappyAction(async () => {
const result = await sessionKill(session.id);
const result = await sessionKill(session.id, session.metadata?.machineId);
if (!result.success) {
throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false);
}
Expand Down
37 changes: 36 additions & 1 deletion packages/happy-app/sources/sync/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ export interface ResumeSessionOptions {
sessionId: string;
}

export interface StopSessionOptions {
machineId: string;
sessionId: string;
}

// Exported session operation functions

/**
Expand Down Expand Up @@ -195,6 +200,27 @@ export async function machineResumeSession(options: ResumeSessionOptions): Promi
}
}

export async function machineStopSession(options: StopSessionOptions): Promise<{ success: boolean; message: string }> {
const { machineId, sessionId } = options;

try {
const result = await apiSocket.machineRPC<{ message: string }, { sessionId: string }>(
machineId,
'stop-session',
{ sessionId },
);
return {
success: true,
message: result.message,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to stop session',
};
}
}

/**
* Stop the daemon on a specific machine
*/
Expand Down Expand Up @@ -486,7 +512,7 @@ export async function sessionRipgrep(
/**
* Kill the session process immediately
*/
export async function sessionKill(sessionId: string): Promise<SessionKillResponse> {
export async function sessionKill(sessionId: string, machineId?: string): Promise<SessionKillResponse> {
try {
const response = await apiSocket.sessionRPC<SessionKillResponse, {}>(
sessionId,
Expand All @@ -495,6 +521,15 @@ export async function sessionKill(sessionId: string): Promise<SessionKillRespons
);
return response;
} catch (error) {
if (machineId) {
const fallback = await machineStopSession({ machineId, sessionId });
if (fallback.success) {
return {
success: true,
message: fallback.message,
};
}
}
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error'
Expand Down
93 changes: 54 additions & 39 deletions packages/happy-app/sources/sync/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,51 @@ class Sync {
return lock;
}

private async fetchNormalizedMessagesPage(sourceSessionId: string, afterSeq: number): Promise<{
normalizedMessages: NormalizedMessage[];
maxSeq: number;
hasMore: boolean;
}> {
const encryption = this.encryption.getSessionEncryption(sourceSessionId);
if (!encryption) {
throw new Error(`Session encryption not ready for ${sourceSessionId}`);
}

const response = await apiSocket.request(`/v3/sessions/${sourceSessionId}/messages?after_seq=${afterSeq}&limit=100`);
if (!response.ok) {
throw new Error(`Failed to fetch messages for ${sourceSessionId}: ${response.status}`);
}

const data = await response.json() as V3GetSessionMessagesResponse;
const messages = Array.isArray(data.messages) ? data.messages : [];

let maxSeq = afterSeq;
for (const message of messages) {
if (message.seq > maxSeq) {
maxSeq = message.seq;
}
}

const decryptedMessages = await encryption.decryptMessages(messages);
const normalizedMessages: NormalizedMessage[] = [];
for (let i = 0; i < decryptedMessages.length; i++) {
const decrypted = decryptedMessages[i];
if (!decrypted) {
continue;
}
const normalized = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content);
if (normalized) {
normalizedMessages.push(normalized);
}
}

return {
normalizedMessages,
maxSeq,
hasMore: !!data.hasMore,
};
}

private scheduleQueuedMessagesProcessing(sessionId: string) {
if (this.sessionQueueProcessing.has(sessionId)) {
return;
Expand Down Expand Up @@ -1593,56 +1638,26 @@ class Sync {
log.log(`💬 fetchMessages starting for session ${sessionId} - acquiring lock`);
const lock = this.getSessionMessageLock(sessionId);
await lock.inLock(async () => {
const encryption = this.encryption.getSessionEncryption(sessionId);
if (!encryption) {
log.log(`💬 fetchMessages: Session encryption not ready for ${sessionId}, will retry`);
throw new Error(`Session encryption not ready for ${sessionId}`);
}
let totalNormalized = 0;

let afterSeq = this.sessionLastSeq.get(sessionId) ?? 0;
let hasMore = true;
let totalNormalized = 0;

while (hasMore) {
const response = await apiSocket.request(`/v3/sessions/${sessionId}/messages?after_seq=${afterSeq}&limit=100`);
if (!response.ok) {
throw new Error(`Failed to fetch messages for ${sessionId}: ${response.status}`);
}
const data = await response.json() as V3GetSessionMessagesResponse;
const messages = Array.isArray(data.messages) ? data.messages : [];

let maxSeq = afterSeq;
for (const message of messages) {
if (message.seq > maxSeq) {
maxSeq = message.seq;
}
}

const decryptedMessages = await encryption.decryptMessages(messages);
const normalizedMessages: NormalizedMessage[] = [];
for (let i = 0; i < decryptedMessages.length; i++) {
const decrypted = decryptedMessages[i];
if (!decrypted) {
continue;
}
const normalized = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content);
if (normalized) {
normalizedMessages.push(normalized);
}
}
const page = await this.fetchNormalizedMessagesPage(sessionId, afterSeq);

if (normalizedMessages.length > 0) {
totalNormalized += normalizedMessages.length;
this.enqueueMessages(sessionId, normalizedMessages);
if (page.normalizedMessages.length > 0) {
totalNormalized += page.normalizedMessages.length;
this.enqueueMessages(sessionId, page.normalizedMessages);
}

this.sessionLastSeq.set(sessionId, maxSeq);
hasMore = !!data.hasMore;
if (hasMore && maxSeq === afterSeq) {
this.sessionLastSeq.set(sessionId, page.maxSeq);
hasMore = page.hasMore;
if (hasMore && page.maxSeq === afterSeq) {
log.log(`💬 fetchMessages: pagination stalled for ${sessionId}, stopping to avoid infinite loop`);
break;
}
afterSeq = maxSeq;
afterSeq = page.maxSeq;
}

storage.getState().applyMessagesLoaded(sessionId);
Expand Down
24 changes: 24 additions & 0 deletions packages/happy-app/sources/utils/resumeCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ describe('buildResumeCommand', () => {
})).toBe('happy claude --resume 93a9705e-bc6a-406d-8dce-8acc014dedbd');
});

it('prefers happy resume when requested and a Happy session ID is available', () => {
expect(buildResumeCommand({
path: '/tmp/project',
os: 'darwin',
happySessionId: 'session-123',
preferHappyResume: true,
})).toBe(`cd '/tmp/project' && happy resume session-123`);
});

it('returns null when there is no resumable session identifier', () => {
expect(buildResumeCommand({
path: '/tmp/project',
Expand Down Expand Up @@ -61,6 +70,21 @@ describe('buildResumeCommandBlock', () => {
});
});

it('builds a happy resume command block when requested', () => {
expect(buildResumeCommandBlock({
path: '/tmp/project',
os: 'darwin',
happySessionId: 'session-123',
preferHappyResume: true,
})).toEqual({
lines: [
`cd '/tmp/project'`,
'happy resume session-123',
],
copyText: `cd '/tmp/project'\nhappy resume session-123`,
});
});

it('builds copyable two-line Windows instructions using PowerShell directory navigation', () => {
expect(buildResumeCommandBlock({
path: 'C:\\Users\\test\\project',
Expand Down
6 changes: 6 additions & 0 deletions packages/happy-app/sources/utils/resumeCommand.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type ResumeCommandMetadata = {
happySessionId?: string | null;
preferHappyResume?: boolean;
path?: string | null;
os?: string | null;
flavor?: string | null;
Expand All @@ -24,6 +26,10 @@ function isWindows(metadata: ResumeCommandMetadata): boolean {
}

function buildResumeInvocation(metadata: ResumeCommandMetadata): string | null {
const happySessionId = metadata.happySessionId?.trim();
if (metadata.preferHappyResume && happySessionId) {
return `happy resume ${happySessionId}`;
}
if ((metadata.flavor === 'codex' || metadata.flavor === 'openai' || metadata.flavor === 'gpt') && metadata.codexThreadId) {
return `happy codex --resume ${metadata.codexThreadId}`;
}
Expand Down
16 changes: 13 additions & 3 deletions packages/happy-app/sources/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,21 @@ export function getSessionAvatarId(session: Session): string {
* Uses flavor-specific commands which work without happy-agent auth.
*/
export function getResumeCommand(session: Session): string | null {
return buildResumeCommand(session.metadata ?? {});
return buildResumeCommand({
...(session.metadata ?? {}),
happySessionId: session.id,
});
}

export function getResumeCommandBlock(session: Session): ResumeCommandBlock | null {
return buildResumeCommandBlock(session.metadata ?? {});
export function getResumeCommandBlock(
session: Session,
options?: { preferHappyResume?: boolean },
): ResumeCommandBlock | null {
return buildResumeCommandBlock({
...(session.metadata ?? {}),
happySessionId: session.id,
preferHappyResume: options?.preferHappyResume ?? false,
});
}

/**
Expand Down
Loading