diff --git a/js/desktop/src/api/types.ts b/js/desktop/src/api/types.ts
index 90cfbe2..03807d1 100644
--- a/js/desktop/src/api/types.ts
+++ b/js/desktop/src/api/types.ts
@@ -244,6 +244,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()),
+ // Set to created_at on creation, bumped when children are added — keeps
+ // folders present in activity-ordered container queries.
+ last_child_created_at: z.coerce.date().optional(),
}),
ParticleBaseSchema.extend({
type: z.literal('media'),
diff --git a/js/desktop/src/features/particles/particle-view-resolver.tsx b/js/desktop/src/features/particles/particle-view-resolver.tsx
index 2770522..bb3be17 100644
--- a/js/desktop/src/features/particles/particle-view-resolver.tsx
+++ b/js/desktop/src/features/particles/particle-view-resolver.tsx
@@ -7,6 +7,7 @@ import { useLiveParticle } from '@/hooks/use-particle';
import { particlePath, type ParticlePath } from '@/lib/particle-path';
import { Button } from '@/components/ui/button';
+import Layout from '@/features/layout';
import { StreamView } from '@/features/particles/stream-view';
import { FolderView } from '@/features/particles/folder-view';
import { MediaParticleView } from '@/features/particles/media-particle-view';
@@ -32,9 +33,11 @@ export default function ParticleViewResolver() {
if (isLoading) {
return (
-
+
+
+
);
}
@@ -42,21 +45,33 @@ export default function ParticleViewResolver() {
// Errors here are almost always Firestore permission-denied — the user lost
// access to the network or to a custom-visibility particle. The React Router
// stays on the dead route, so without an explicit escape the user is stuck.
- return ;
+ return (
+
+
+
+ );
}
+ // Streams render their own full-screen chrome; folders and leaves live
+ // inside the app Layout (breadcrumbs, full-height column) like the root.
switch (particle.type) {
case 'stream':
return ;
case 'folder':
- return ;
+ return (
+
+
+
+ );
default:
return (
-
+
+
+
);
}
}
@@ -133,7 +148,7 @@ function LeafParticleView({
})();
return (
-
+
{content}
);
diff --git a/js/desktop/src/hooks/use-container-children.ts b/js/desktop/src/hooks/use-container-children.ts
index 06e7462..81f8db6 100644
--- a/js/desktop/src/hooks/use-container-children.ts
+++ b/js/desktop/src/hooks/use-container-children.ts
@@ -28,7 +28,10 @@ interface UseContainerChildrenResult {
}
function activityTime(particle: Particle): number {
- if (particle.type === 'stream' && particle.last_child_created_at) {
+ if (
+ (particle.type === 'stream' || particle.type === 'folder') &&
+ particle.last_child_created_at
+ ) {
return particle.last_child_created_at.getTime();
}
return particle.created_at.getTime();
diff --git a/js/desktop/src/lib/firestore-particles.ts b/js/desktop/src/lib/firestore-particles.ts
index a0377c6..4bcf8ef 100644
--- a/js/desktop/src/lib/firestore-particles.ts
+++ b/js/desktop/src/lib/firestore-particles.ts
@@ -116,6 +116,9 @@ const particleConverter: FirestoreDataConverter
= {
? (raw.updated_at as Timestamp).toDate()
: undefined,
visible_to: raw.visible_to,
+ last_child_created_at: raw.last_child_created_at
+ ? (raw.last_child_created_at as Timestamp).toDate()
+ : undefined,
});
case 'media':
case 'file':
@@ -281,6 +284,25 @@ export function subscribeToLatestChild(
);
}
+// Best-effort bump of the parent container's last_child_created_at when a
+// child is created. The particle processor worker does this for stream
+// parents, but skips folders, so the client keeps folder activity fresh
+// itself. Same value semantics as the worker: the child's created_at, so it
+// stays directly comparable with playback markers.
+function bumpParentLastChildCreatedAt(
+ collectionPath: string,
+ childCreatedAt: Date,
+): void {
+ const parentDocPath = collectionPath.replace(/\/children$/, '');
+ // The network root (networks/{id}) is not a particle doc — nothing to bump.
+ if (!parentDocPath.includes('/children/')) return;
+ updateDoc(doc(firestoreDb, parentDocPath), {
+ last_child_created_at: Timestamp.fromDate(childCreatedAt),
+ }).catch(() => {
+ // Non-fatal: ordering freshness only.
+ });
+}
+
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle(
collectionPath: string,
@@ -296,15 +318,21 @@ export async function createParticle(
);
}
+ const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '', // ignored by toFirestore, but needed to satisfy the type
type,
properties,
- created_at: new Date(),
+ created_at: createdAt,
created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}),
+ // Containers start with last_child_created_at = created_at so they appear
+ // in activity-ordered queries before they have any children (Firestore
+ // orderBy drops docs missing the field).
+ ...(isContainerType(type) ? { last_child_created_at: createdAt } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
+ bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}
@@ -318,15 +346,18 @@ export async function createStreamParticle(
throw new Error('visibleTo is required for streams and cannot be empty');
}
+ const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '',
type: 'stream',
properties,
- created_at: new Date(),
+ created_at: createdAt,
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
+ last_child_created_at: createdAt,
});
const ref = await addDoc(typedCollection(collectionPath), particle);
+ bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}