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
48 changes: 46 additions & 2 deletions packages/happy-app/sources/components/SessionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { useRouter } from 'expo-router';
import { Item } from './Item';
import { ItemGroup } from './ItemGroup';
import { useHappyAction } from '@/hooks/useHappyAction';
import { sessionDelete } from '@/sync/ops';
import { sessionDelete, sessionRename } from '@/sync/ops';
import { HappyError } from '@/utils/errors';
import { Modal } from '@/modal';

Expand Down Expand Up @@ -192,6 +192,9 @@ const stylesheet = StyleSheet.create((theme) => ({
textAlign: 'center',
...Typography.default('semiBold'),
},
swipeActionRename: {
backgroundColor: theme.colors.primary,
},
}));

export function SessionsList() {
Expand Down Expand Up @@ -346,6 +349,13 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
}
});

const [renamingSession, performRename] = useHappyAction(async (newName: string | null) => {
const result = await sessionRename(session.id, newName);
if (!result.success) {
throw new HappyError(result.message || t('sessionInfo.failedToRenameSession'), false);
}
});

const handleDelete = React.useCallback(() => {
swipeableRef.current?.close();
Modal.alert(
Expand All @@ -362,6 +372,25 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
);
}, [performDelete]);

const handleRename = React.useCallback(async () => {
swipeableRef.current?.close();
const currentName = session.customName || getSessionName(session);
const newName = await Modal.prompt(
t('sessionInfo.renameSession'),
t('sessionInfo.renameSessionPrompt'),
{
placeholder: t('sessionInfo.sessionNamePlaceholder'),
defaultValue: currentName,
confirmText: t('common.rename'),
cancelText: t('common.cancel')
}
);

if (newName !== null && newName !== currentName) {
await performRename(newName || null);
}
}, [session, performRename]);

const avatarId = React.useMemo(() => {
return getSessionAvatarId(session);
}, [session]);
Expand Down Expand Up @@ -445,6 +474,19 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
);
}

const renderLeftActions = () => (
<Pressable
style={[styles.swipeAction, styles.swipeActionRename]}
onPress={handleRename}
disabled={renamingSession}
>
<Ionicons name="create-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('common.rename')}
</Text>
</Pressable>
);

const renderRightActions = () => (
<Pressable
style={styles.swipeAction}
Expand All @@ -462,9 +504,11 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
<View style={containerStyles}>
<Swipeable
ref={swipeableRef}
renderLeftActions={renderLeftActions}
renderRightActions={renderRightActions}
overshootLeft={false}
overshootRight={false}
enabled={!deletingSession}
enabled={!deletingSession && !renamingSession}
>
{itemContent}
</Swipeable>
Expand Down
33 changes: 32 additions & 1 deletion packages/happy-app/sources/sync/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,37 @@ export async function sessionKill(sessionId: string): Promise<SessionKillRespons
}
}

/**
* Rename a session by setting its customName
* Pass null to clear the custom name
*/
export async function sessionRename(sessionId: string, customName: string | null): Promise<{ success: boolean; message?: string }> {
try {
const response = await apiSocket.request(`/v1/sessions/${sessionId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ customName })
});

if (response.ok) {
return { success: true };
} else {
const error = await response.text();
return {
success: false,
message: error || 'Failed to rename session'
};
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}

/**
* Permanently delete a session from the server
* This will remove the session and all its associated data (messages, usage reports, access keys)
Expand All @@ -501,7 +532,7 @@ export async function sessionDelete(sessionId: string): Promise<{ success: boole
const response = await apiSocket.request(`/v1/sessions/${sessionId}`, {
method: 'DELETE'
});

if (response.ok) {
const result = await response.json();
return { success: true };
Expand Down
5 changes: 5 additions & 0 deletions packages/happy-app/sources/text/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,11 @@ export const en: TranslationStructure = {
deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.',
failedToDeleteSession: 'Failed to delete session',
sessionDeleted: 'Session deleted successfully',
renameSession: 'Rename Session',
renameSessionPrompt: 'Enter a new name for this session',
sessionNamePlaceholder: 'Session name',
sessionRenamed: 'Session renamed successfully',
failedToRenameSession: 'Failed to rename session',

},

Expand Down
5 changes: 5 additions & 0 deletions packages/happy-server/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ S3_PUBLIC_URL=http://localhost:9000/happy
# Secret - congiured in .env, not checked in

NODE_ENV=development

# --- Session & Machine Timeout ---
# Session/Machine timeout in minutes (default: 30)
SESSION_TIMEOUT_MINUTES=30
MACHINE_TIMEOUT_MINUTES=30
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Session" ADD COLUMN "customName" TEXT;
1 change: 1 addition & 0 deletions packages/happy-server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ model Session {
tag String
accountId String
account Account @relation(fields: [accountId], references: [id])
customName String? // Custom name for the session (allows rename)
metadata String
metadataVersion Int @default(0)
agentState String?
Expand Down
34 changes: 34 additions & 0 deletions packages/happy-server/sources/app/api/routes/sessionRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { log } from "@/utils/log";
import { randomKeyNaked } from "@/utils/randomKeyNaked";
import { allocateUserSeq } from "@/storage/seq";
import { sessionDelete } from "@/app/session/sessionDelete";
import { sessionRename } from "@/app/session/sessionRename";

export function sessionRoutes(app: Fastify) {

Expand All @@ -25,6 +26,7 @@ export function sessionRoutes(app: Fastify) {
seq: true,
createdAt: true,
updatedAt: true,
customName: true,
metadata: true,
metadataVersion: true,
agentState: true,
Expand Down Expand Up @@ -59,6 +61,7 @@ export function sessionRoutes(app: Fastify) {
updatedAt: sessionUpdatedAt,
active: v.active,
activeAt: v.lastActiveAt.getTime(),
customName: v.customName,
metadata: v.metadata,
metadataVersion: v.metadataVersion,
agentState: v.agentState,
Expand Down Expand Up @@ -95,6 +98,7 @@ export function sessionRoutes(app: Fastify) {
seq: true,
createdAt: true,
updatedAt: true,
customName: true,
metadata: true,
metadataVersion: true,
agentState: true,
Expand All @@ -113,6 +117,7 @@ export function sessionRoutes(app: Fastify) {
updatedAt: v.updatedAt.getTime(),
active: v.active,
activeAt: v.lastActiveAt.getTime(),
customName: v.customName,
metadata: v.metadata,
metadataVersion: v.metadataVersion,
agentState: v.agentState,
Expand Down Expand Up @@ -175,6 +180,7 @@ export function sessionRoutes(app: Fastify) {
seq: true,
createdAt: true,
updatedAt: true,
customName: true,
metadata: true,
metadataVersion: true,
agentState: true,
Expand Down Expand Up @@ -204,6 +210,7 @@ export function sessionRoutes(app: Fastify) {
updatedAt: v.updatedAt.getTime(),
active: v.active,
activeAt: v.lastActiveAt.getTime(),
customName: v.customName,
metadata: v.metadata,
metadataVersion: v.metadataVersion,
agentState: v.agentState,
Expand Down Expand Up @@ -242,6 +249,7 @@ export function sessionRoutes(app: Fastify) {
session: {
id: session.id,
seq: session.seq,
customName: session.customName,
metadata: session.metadata,
metadataVersion: session.metadataVersion,
agentState: session.agentState,
Expand Down Expand Up @@ -290,6 +298,7 @@ export function sessionRoutes(app: Fastify) {
session: {
id: session.id,
seq: session.seq,
customName: session.customName,
metadata: session.metadata,
metadataVersion: session.metadataVersion,
agentState: session.agentState,
Expand Down Expand Up @@ -354,6 +363,31 @@ export function sessionRoutes(app: Fastify) {
});
});

// Rename session
app.patch('/v1/sessions/:sessionId', {
schema: {
params: z.object({
sessionId: z.string()
}),
body: z.object({
customName: z.string().nullable()
})
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.userId;
const { sessionId } = request.params;
const { customName } = request.body;

const renamed = await sessionRename({ uid: userId }, sessionId, customName);

if (!renamed) {
return reply.code(404).send({ error: 'Session not found or not owned by user' });
}

return reply.send({ success: true });
});

// Delete session
app.delete('/v1/sessions/:sessionId', {
schema: {
Expand Down
11 changes: 9 additions & 2 deletions packages/happy-server/sources/app/presence/timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ import { shutdownSignal } from "@/utils/shutdown";
import { buildMachineActivityEphemeral, buildSessionActivityEphemeral, eventRouter } from "@/app/events/eventRouter";

export function startTimeout() {
// Get timeout values from environment or use defaults
const sessionTimeoutMinutes = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '30', 10);
const machineTimeoutMinutes = parseInt(process.env.MACHINE_TIMEOUT_MINUTES || '30', 10);

console.log(`[Timeout] Session timeout: ${sessionTimeoutMinutes} minutes`);
console.log(`[Timeout] Machine timeout: ${machineTimeoutMinutes} minutes`);

forever('session-timeout', async () => {
while (true) {
// Find timed out sessions
const sessions = await db.session.findMany({
where: {
active: true,
lastActiveAt: {
lte: new Date(Date.now() - 1000 * 60 * 10) // 10 minutes
lte: new Date(Date.now() - 1000 * 60 * sessionTimeoutMinutes)
}
}
});
Expand All @@ -36,7 +43,7 @@ export function startTimeout() {
where: {
active: true,
lastActiveAt: {
lte: new Date(Date.now() - 1000 * 60 * 10) // 10 minutes
lte: new Date(Date.now() - 1000 * 60 * machineTimeoutMinutes)
}
}
});
Expand Down
75 changes: 75 additions & 0 deletions packages/happy-server/sources/app/session/sessionRename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Context } from "@/context";
import { inTx, afterTx } from "@/storage/inTx";
import { eventRouter, buildUpdateSessionUpdate } from "@/app/events/eventRouter";
import { allocateUserSeq } from "@/storage/seq";
import { randomKeyNaked } from "@/utils/randomKeyNaked";
import { log } from "@/utils/log";

/**
* Rename a session by setting its customName field.
*
* @param ctx - Context with user information
* @param sessionId - ID of the session to rename
* @param customName - New custom name (null to clear)
* @returns true if rename was successful, false if session not found or not owned by user
*/
export async function sessionRename(ctx: Context, sessionId: string, customName: string | null): Promise<boolean> {
return await inTx(async (tx) => {
// Verify session exists and belongs to the user
const session = await tx.session.findFirst({
where: {
id: sessionId,
accountId: ctx.uid
}
});

if (!session) {
log({
module: 'session-rename',
userId: ctx.uid,
sessionId
}, `Session not found or not owned by user`);
return false;
}

// Update customName
const updatedSession = await tx.session.update({
where: { id: sessionId },
data: { customName: customName }
});

log({
module: 'session-rename',
userId: ctx.uid,
sessionId,
customName
}, `Session renamed successfully`);

// Send notification after transaction commits
afterTx(tx, async () => {
const updSeq = await allocateUserSeq(ctx.uid);
const updatePayload = buildUpdateSessionUpdate(
sessionId,
updSeq,
randomKeyNaked(12),
{ value: updatedSession.metadata, version: updatedSession.metadataVersion }
);

log({
module: 'session-rename',
userId: ctx.uid,
sessionId,
updateType: 'update-session',
updatePayload: JSON.stringify(updatePayload)
}, `Emitting update-session for rename to user-scoped connections`);

eventRouter.emitUpdate({
userId: ctx.uid,
payload: updatePayload,
recipientFilter: { type: 'user-scoped-only' }
});
});

return true;
});
}
Loading