Skip to content
Merged
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
8 changes: 7 additions & 1 deletion apps/x/apps/main/src/spaces/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ type SpacesHandlers = {
'spaces:apexInfo': InvokeHandler<'spaces:apexInfo'>;
'spaces:removeOrg': InvokeHandler<'spaces:removeOrg'>;
'spaces:listSpaces': InvokeHandler<'spaces:listSpaces'>;
'spaces:browseSpaces': InvokeHandler<'spaces:browseSpaces'>;
'spaces:joinSpace': InvokeHandler<'spaces:joinSpace'>;
'spaces:createSpace': InvokeHandler<'spaces:createSpace'>;
'spaces:renameSpace': InvokeHandler<'spaces:renameSpace'>;
'spaces:addMembers': InvokeHandler<'spaces:addMembers'>;
Expand Down Expand Up @@ -227,8 +229,12 @@ export const spacesIpcHandlers: SpacesHandlers = {
return { spaces, ...(groupChat !== undefined ? { groupChat } : {}) };
},

'spaces:browseSpaces': async (_event, args) => orgs.getClient(args.orgId).browseSpaces(),

'spaces:joinSpace': async (_event, args) => orgs.getClient(args.orgId).joinSpace(args.spaceId),

'spaces:createSpace': async (_event, args) => {
const space = await orgs.getClient(args.orgId).createSpace(args.name);
const space = await orgs.getClient(args.orgId).createSpace(args.name, args.visibility);
return { space };
},

Expand Down
17 changes: 12 additions & 5 deletions apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { loadSpaceDirectory, getDirectorySpace } from '@/hooks/use-space-directory'
import { OpenBrowserContext } from '@/contexts/browser-context';
import { WorkspaceSessionTabs } from './components/code/workspace-session-tabs'
import { DocumentFileViewer } from '@/components/document-file-viewer'
Expand Down Expand Up @@ -662,7 +663,7 @@ type ViewState =
spaceId?: string
rail?: RailSelection
/** An org-level surface instead of a space: Activity (layer 3, 2026-09-10). */
view?: 'activity'
view?: 'activity' | 'browse'
/** Scroll to this message once the pane paints (a notification or Activity click). */
messageId?: string
/** Its offset, when the producer fetched the message — the pane can load around it without asking. */
Expand Down Expand Up @@ -766,7 +767,7 @@ function parseDeepLink(input: string): ViewState | null {
// through openSpacesLink, which looks things up first.
const orgId = params.get('orgId')
const spaceId = params.get('spaceId')
if (orgId && params.get('view') === 'activity') return { type: 'spaces', orgId, view: 'activity' }
if (orgId && (params.get('view') === 'activity' || params.get('view') === 'browse')) return { type: 'spaces', orgId, view: params.get('view') as 'activity' | 'browse' }
if (!orgId || !spaceId) return { type: 'spaces' }
const threadRootId = params.get('threadRootId')
const messageId = params.get('messageId')
Expand Down Expand Up @@ -4866,7 +4867,7 @@ function App() {
case 'apps': return 'Apps'
case 'spaces': {
const org = spacesOrgs.find((o) => o.id === currentViewState.orgId)
if (org && currentViewState.view === 'activity') return 'Activity'
if (org && currentViewState.view) return currentViewState.view === 'browse' ? 'Browse spaces' : 'Activity'
const space = org ? findSpace(org, currentViewState.spaceId) : undefined
return org && space ? spaceDisplayName(org, space) : 'Spaces'
}
Expand Down Expand Up @@ -5512,6 +5513,10 @@ function App() {
}, [navigateToView])

/** The org's Activity surface (layer 3): everything that involves you, newest first. */
const openBrowse = useCallback((orgId: string) => {
void navigateToView({ type: 'spaces', orgId, view: 'browse' })
}, [navigateToView])

const openActivity = useCallback((orgId: string) => {
void navigateToView({ type: 'spaces', orgId, view: 'activity' })
}, [navigateToView])
Expand Down Expand Up @@ -5680,8 +5685,9 @@ function App() {
void navigateToViewRef.current({ type: 'spaces', orgId: org.id, view: 'activity' })
return
}
if (!findSpace(org, target.spaceId)) {
toast.error('That link points at a space you are not in')
if (!findSpace(org, target.spaceId)) await loadSpaceDirectory(org.id)
if (!findSpace(org, target.spaceId) && !getDirectorySpace(org.id, target.spaceId)) {
toast.error('This space is unavailable or you do not have access')
void navigateToViewRef.current({ type: 'spaces', orgId: org.id, view: 'activity' })
return
}
Expand Down Expand Up @@ -7680,6 +7686,7 @@ function App() {
// Activity → a message: one navigation entry that opens the
// space (or thread) and lands on the row.
onOpenMessage={(target) => void navigateToView({ type: 'spaces', ...target })}
onOpenBrowse={openBrowse}
onOpenActivity={openActivity}
onOpenSession={openAssistantRun}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe('server and space navigation', () => {
expect(screen.getByText('founders').compareDocumentPosition(newSpace) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(newSpace.compareDocumentPosition(screen.getByRole('heading', { name: 'DMs' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(newSpace)
expect(screen.getByPlaceholderText('Space name').compareDocumentPosition(screen.getByRole('heading', { name: 'DMs' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByRole('dialog', { name: 'Create a space' })).toBeInTheDocument()
})
it('moves every space from the rail\'s one expand-all / collapse-all control', () => {
// The rail header's control, standing in for space-rail.tsx: the space
Expand Down
69 changes: 39 additions & 30 deletions apps/x/apps/renderer/src/components/spaces-sidebar-section.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useSpaceDirectory, invalidateSpaceDirectory } from '@/hooks/use-space-directory'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Bell, ChevronRight, CornerDownRight, Hash, MessagesSquare, Pencil, Plus } from 'lucide-react'
import { Bell, ChevronRight, CornerDownRight, Hash, Lock, MessagesSquare, Pencil, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
import {
Expand Down Expand Up @@ -92,7 +95,7 @@ export function SpacesSidebarSection({ active, activeSpace, onOpenSpace }: {
</SidebarGroup>
}

function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activityActive = false, onChanged, renderDiscussions, showArchived = false, activeDiscussionCount }: {
function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, onOpenBrowse, browseActive = false, activityActive = false, onChanged, renderDiscussions, showArchived = false, activeDiscussionCount }: {
org: OrgWithSpaces
activeSpace: SpaceSelection
unread: Map<string, SpaceBadge>
Expand All @@ -101,6 +104,8 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi
renderDiscussions?: (spaceId: string) => ReactNode
onOpenSpace: (orgId: string, spaceId: string) => void
/** The org's Activity surface (layer 3); absent = no row. */
onOpenBrowse?: (orgId: string) => void
browseActive?: boolean
onOpenActivity?: (orgId: string) => void
activityActive?: boolean
onChanged: () => void
Expand All @@ -111,6 +116,9 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi
for (const [key, badge] of unread) if (key.startsWith(`${org.id}/`)) forYou += badge.forYou
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [visibility, setVisibility] = useState<'private' | 'open'>('private')
const [submitting, setSubmitting] = useState(false)
const directory = useSpaceDirectory(org.id, creating)
// Rename-in-place: the row's label becomes an input (same shape as create).
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
Expand All @@ -134,16 +142,22 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi

const createSpace = async () => {
const name = newName.trim()
if (!name) return
if (!name || submitting || (visibility === 'open' && (!directory.loaded || !directory.supported || directory.error))) return
setSubmitting(true)
try {
const { space } = await window.ipc.invoke('spaces:createSpace', { orgId: org.id, name })
const { space } = await window.ipc.invoke('spaces:createSpace', { orgId: org.id, name, visibility })
if (space.visibility !== visibility) toast('The server created this space as private. Update the server to create open spaces.', 'info')
invalidateSpaceDirectory(org.id)
setVisibility('private')
analytics.spacesSpaceCreated()
setCreating(false)
setNewName('')
onChanged()
onOpenSpace(org.id, space.id)
} catch (err) {
toast(err instanceof Error ? err.message : 'Could not create the space', 'error')
} finally {
setSubmitting(false)
}
}

Expand Down Expand Up @@ -199,6 +213,7 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi

return (
<>
{onOpenBrowse && <SidebarMenuItem><SidebarMenuButton isActive={browseActive} onClick={() => onOpenBrowse(org.id)} className="pl-6">Browse spaces</SidebarMenuButton></SidebarMenuItem>}
{onOpenActivity && (
<SidebarMenuItem>
<SidebarMenuButton isActive={activityActive} onClick={() => onOpenActivity(org.id)} className="pl-6">
Expand Down Expand Up @@ -245,7 +260,7 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi
className="min-w-0 flex-1 pl-1"
>
{/* A space is a channel — # says so. */}
<Hash className="size-3.5 shrink-0 text-muted-foreground" />
{space.visibility === 'open' ? <Hash className="size-3.5 shrink-0 text-muted-foreground" /> : <Lock aria-label="Private space" className="size-3.5 shrink-0 text-muted-foreground" />}
<span className={cn('flex-1 truncate', badge.unread > 0 && !active && 'font-medium text-foreground')}>{space.name}</span>
{!active && <UnreadBadge badge={badge} />}
</SidebarMenuButton>
Expand Down Expand Up @@ -293,29 +308,21 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi

</div>
</SidebarMenuItem>
{creating && (
<SidebarMenuItem>
<div className="flex items-center gap-1 py-0.5 pl-9 pr-2">
<Input
autoFocus
value={newName}
placeholder="Space name"
className="h-7 text-xs"
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void createSpace()
if (e.key === 'Escape') {
setCreating(false)
setNewName('')
}
}}
onBlur={() => {
if (!newName.trim()) setCreating(false)
}}
/>
</div>
</SidebarMenuItem>
)}
<Dialog open={creating} onOpenChange={(open) => { if (!submitting) { setCreating(open); if (!open) { setNewName(''); setVisibility('private') } } }}>
<DialogContent>
<DialogHeader><DialogTitle>Create a space</DialogTitle><DialogDescription>Choose who can discover and read this space.</DialogDescription></DialogHeader>
<form className="flex flex-col gap-4" onSubmit={event => { event.preventDefault(); void createSpace() }}>
<label className="text-sm">Name<Input autoFocus maxLength={128} value={newName} onChange={event => setNewName(event.target.value)} placeholder="Space name" /></label>
<fieldset className="space-y-3" disabled={submitting}><legend className="mb-2 text-sm font-medium">Visibility</legend>
<label className="flex items-start gap-2 text-sm"><input type="radio" name="visibility" checked={visibility === 'private'} onChange={() => setVisibility('private')} /><span>Private<span className="block text-xs text-muted-foreground">Only space members can read and participate.</span></span></label>
<label className="flex items-start gap-2 text-sm"><input type="radio" name="visibility" checked={visibility === 'open'} disabled={!directory.loaded || !directory.supported || !!directory.error} onChange={() => setVisibility('open')} /><span>Open<span className="block text-xs text-muted-foreground">Anyone in the team can browse and join.</span></span></label>
</fieldset>
{directory.error && <p role="alert" className="text-sm text-muted-foreground">Could not check open-space support. You can still create a private space.</p>}
{directory.loaded && !directory.supported && <p className="text-sm text-muted-foreground">Update this server to create open spaces.</p>}
<Button type="submit" disabled={!newName.trim() || submitting}>{submitting ? 'Creating…' : 'Create space'}</Button>
</form>
</DialogContent>
</Dialog>
{/* Direct messages: the org's people you talk to, most recent first.
A DM is a space with a two-person roster (contract 2026-09-07);
the row is the person, not a channel. */}
Expand Down Expand Up @@ -383,10 +390,12 @@ function OrgRows({ org, activeSpace, unread, onOpenSpace, onOpenActivity, activi
}

/** Space and DM destinations; nested discussions are optional for legacy consumers. */
export function ServerSpaceNavigation({ org, spaceId, onOpenSpace, onOpenActivity, activityActive = false, onOpenDiscussion, renderActiveDiscussions, activeDiscussionCount = 0, showArchived = false, showDiscussions = true }: {
export function ServerSpaceNavigation({ org, spaceId, onOpenSpace, onOpenActivity, onOpenBrowse, browseActive = false, activityActive = false, onOpenDiscussion, renderActiveDiscussions, activeDiscussionCount = 0, showArchived = false, showDiscussions = true }: {
org: OrgWithSpaces
spaceId: string
onOpenSpace: (orgId: string, spaceId: string) => void
onOpenBrowse?: (orgId: string) => void
browseActive?: boolean
onOpenActivity?: (orgId: string) => void
activityActive?: boolean
onOpenDiscussion?: (spaceId: string, selection: RailSelection) => void
Expand All @@ -399,7 +408,7 @@ export function ServerSpaceNavigation({ org, spaceId, onOpenSpace, onOpenActivit
const unread = useSpacesUnreadCounts()
return <SidebarMenu>
<OrgRows org={org} activeSpace={{ orgId: org.id, spaceId }} unread={unread} showArchived={showArchived} activeDiscussionCount={activeDiscussionCount}
onOpenSpace={onOpenSpace} onOpenActivity={onOpenActivity} activityActive={activityActive} onChanged={() => void refresh()}
onOpenSpace={onOpenSpace} onOpenActivity={onOpenActivity} onOpenBrowse={onOpenBrowse} browseActive={browseActive} activityActive={activityActive} onChanged={() => void refresh()}
renderDiscussions={showDiscussions && renderActiveDiscussions ? (id) => <SpaceDiscussions orgId={org.id} spaceId={id}
active={id === spaceId} activeCount={activeDiscussionCount} showArchived={showArchived} renderActive={renderActiveDiscussions}
onSelect={(selection) => onOpenDiscussion?.(id, selection)} /> : undefined} />
Expand Down
Loading
Loading