Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
dc05631
Add organizations.slug column
evanjacobson Jun 25, 2026
4d08887
Undo migration
evanjacobson Jun 25, 2026
826fb2f
Add migration for two new columns on organizations table: slug, reque…
evanjacobson Jun 25, 2026
1144d7c
Reorder organization-types col ordering to match schema & make migrat…
evanjacobson Jun 25, 2026
4a6781c
feat(organizations): manage requested slugs
evanjacobson Jun 25, 2026
cfff64e
Only display slug request to the relevant users
evanjacobson Jun 25, 2026
ea9b6f3
Enforce max length 32 for org slugs (UUIDs are 38)
evanjacobson Jun 25, 2026
7e2b438
Enforce org slug max length + Add resolution helper in the code
evanjacobson Jun 25, 2026
ce83454
Org slugs resolve in url (init)
evanjacobson Jun 25, 2026
76d5795
Streamline migration
evanjacobson Jun 25, 2026
4ed4ef8
Allow org owners to edit slug
evanjacobson Jun 25, 2026
5b5f534
fix(orgs): resolve slug route context in sidebar
evanjacobson Jun 25, 2026
f222f94
cleanly print slug name error
evanjacobson Jun 25, 2026
addfc51
Forbid underscores
evanjacobson Jun 25, 2026
312962e
fix(orgs): add admin slug backfill
evanjacobson Jun 25, 2026
7487b74
fix(orgs): restrict kilo slug generation
evanjacobson Jun 25, 2026
2aa4c7d
fix(web): resolve organization routes canonically
evanjacobson Jun 25, 2026
fce35f3
fix(admin): manage organization slugs
evanjacobson Jun 25, 2026
4c0ca0a
feat(organizations): resolve route identifiers
evanjacobson Jun 25, 2026
aa94ac6
fix(organizations): enforce slug route boundaries
evanjacobson Jun 25, 2026
8ab5889
fix(organizations): harden slug route handling
evanjacobson Jun 25, 2026
697bc03
fix(organizations): use slugs in org URLs
evanjacobson Jun 25, 2026
eb59d94
fix(organizations): use slugs in admin org URLs
evanjacobson Jun 26, 2026
f51f595
NewSessionPanel used organizationRouteIdentifier ?? organizationId fo…
evanjacobson Jun 26, 2026
cdaae41
fix(organizations): use stable ID for chat sidebar routes
evanjacobson Jun 26, 2026
6553569
Confirmed the review feedback: producer schema still allows organizat…
evanjacobson Jun 26, 2026
e8704aa
fix(organizations): resolve slug route merge conflicts
evanjacobson Jun 26, 2026
389f44e
Update org slug backfill button label to reflect the 1000 batch size
evanjacobson Jun 26, 2026
dbca7b2
fix typecheck
evanjacobson Jun 26, 2026
1b57afe
fix tests
evanjacobson Jun 26, 2026
c4d933a
Fix oauth error routing
evanjacobson Jun 26, 2026
0b19c0f
format file
evanjacobson Jun 26, 2026
8b1e34c
Fix org slug redirect regressions
evanjacobson Jun 26, 2026
471efa3
Fix org slug review regressions
evanjacobson Jun 26, 2026
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
5 changes: 3 additions & 2 deletions apps/storybook/src/mockData/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function generateMember(): OrganizationMember {
{
id: randomId(rng, 'org'),
name: `Team ${randomInt(rng, 1, 9)}`,
slug: `team-${randomInt(rng, 1, 9)}`,
role: 'member',
},
]
Expand Down Expand Up @@ -56,8 +57,8 @@ export function generateOrganization(): OrganizationWithMembers {
...base,
name: `Company ${randomInt(rng, 0, 999)} ${companyType}`,
childOrganizations: [
{ id: randomId(rng, 'org'), name: 'Platform Team' },
{ id: randomId(rng, 'org'), name: 'Product Team' },
{ id: randomId(rng, 'org'), name: 'Platform Team', slug: 'platform-team' },
{ id: randomId(rng, 'org'), name: 'Product Team', slug: 'product-team' },
],
members: Array.from({ length: randomInt(rng, 2, 7) }, generateMember),
effectiveSsoPolicy: {
Expand Down
9 changes: 8 additions & 1 deletion apps/storybook/stories/OrganizationSwitcher.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ type OrganizationSwitcherStoryProps = {
const organizations: OrganizationSwitcherOrganization[] = [
{
organizationId: 'org-kilo',
organizationSlug: 'kilo-code',
organizationName: 'Kilo Code',
role: 'owner',
},
{
organizationId: 'org-design',
organizationSlug: 'design-systems',
organizationName: 'Design Systems',
role: 'member',
},
{
organizationId: 'org-cloud',
organizationSlug: 'cloud-platform',
organizationName: 'Cloud Platform',
role: 'member',
},
Expand All @@ -34,14 +37,18 @@ function OrganizationSwitcherStory({
}: OrganizationSwitcherStoryProps) {
const [organizationId, setOrganizationId] = useState(initialOrganizationId);

const handleOrganizationSwitch = (organization: OrganizationSwitcherOrganization | null) => {
setOrganizationId(organization?.organizationId ?? null);
};

return (
<div className="bg-background p-6">
<div className="bg-sidebar text-sidebar-foreground w-64 rounded-lg p-4">
<OrganizationSwitcherView
organizationId={organizationId}
organizations={organizations}
isPending={isPending}
onOrganizationSwitch={setOrganizationId}
onOrganizationSwitch={handleOrganizationSwitch}
/>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/storybook/stories/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@ const mockUser = {
const mockOrganizations = [
{
organizationId: 'org-kilo',
organizationSlug: 'kilo-code',
organizationName: 'Kilo Code',
role: 'owner',
},
{
organizationId: 'org-design',
organizationSlug: 'design-systems',
organizationName: 'Design Systems',
role: 'member',
},
Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,15 @@ const PLATFORM_OPTIONS: readonly {

type PlatformFilterValue = 'all' | 'cloud-agent' | 'cli' | 'agent-manager' | 'gastown' | 'other';

export function SessionsPageContent() {
type SessionsPageContentProps = {
organizationId?: string;
organizationRouteIdentifier?: string;
};

export function SessionsPageContent({
organizationId,
organizationRouteIdentifier,
}: SessionsPageContentProps = {}) {
const trpc = useTRPC();
const pathname = usePathname();
const [searchQuery, setSearchQuery] = useState('');
Expand All @@ -65,11 +73,12 @@ export function SessionsPageContent() {
return () => clearTimeout(timer);
}, [searchQuery]);

// Determine if we're in an organization context
const organizationId = pathname.match(/^\/organizations\/([^/]+)/)?.[1];

// When in organization context, OrganizationTrialWrapper already provides PageContainer
const shouldUsePageContainer = !organizationId;
const organizationPathIdentifier =
organizationRouteIdentifier ??
organizationId ??
pathname.match(/^\/organizations\/([^/]+)/)?.[1];

const isSearching = debouncedSearchQuery.trim().length > 0;

Expand Down Expand Up @@ -242,7 +251,7 @@ export function SessionsPageContent() {
<Link
href={
organizationId
? `/organizations/${organizationId}/cloud/chat?sessionId=${selectedSession.sessionId}`
? `/organizations/${organizationPathIdentifier}/cloud/chat?sessionId=${selectedSession.sessionId}`
: `/cloud/chat?sessionId=${selectedSession.sessionId}`
}
onClick={() => setIsDialogOpen(false)}
Expand Down
81 changes: 68 additions & 13 deletions apps/web/src/app/(app)/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,43 @@

import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { useSidebar, type Sidebar } from '@/components/ui/sidebar';
import { useUrlOrganizationId } from '@/hooks/useUrlOrganizationId';
import { useUrlOrganizationIdentifier } from '@/hooks/useUrlOrganizationId';
import {
findOrganizationByRouteIdentifier,
isUuidOrganizationRouteIdentifier,
} from '@/lib/organizations/organization-route-utils';
import { useTRPC } from '@/lib/trpc/utils';
import PersonalAppSidebar from './PersonalAppSidebar';
import OrganizationAppSidebar from './OrganizationAppSidebar';
import { GastownTownSidebar } from '@/components/gastown/GastownTownSidebar';
import { WastelandSidebar } from '@/components/wasteland/WastelandSidebar';

const UUID = '[0-9a-f-]{36}';
const ORG_ROUTE_IDENTIFIER = '[^/]+';

/** Extract the townId from a /gastown/[townId] pathname, or null. */
function extractGastownTownId(pathname: string): string | null {
const match = pathname.match(new RegExp(`^/gastown/(${UUID})`));
return match ? match[1] : null;
}

/** Extract {orgId, townId} from an /organizations/[id]/gastown/[townId] pathname, or null. */
function extractOrgGastownTownId(pathname: string): { orgId: string; townId: string } | null {
const match = pathname.match(new RegExp(`^/organizations/(${UUID})/gastown/(${UUID})`));
return match ? { orgId: match[1], townId: match[2] } : null;
/** Extract {orgIdentifier, townId} from an /organizations/[id]/gastown/[townId] pathname, or null. */
function extractOrgGastownTownId(
pathname: string
): { orgIdentifier: string; townId: string } | null {
const match = pathname.match(
new RegExp(`^/organizations/(${ORG_ROUTE_IDENTIFIER})/gastown/(${UUID})`)
);
return match ? { orgIdentifier: decodeURIComponent(match[1]), townId: match[2] } : null;
}

function isKiloClawNewPath(pathname: string): boolean {
return pathname === '/claw/new' || new RegExp(`^/organizations/${UUID}/claw/new$`).test(pathname);
return (
pathname === '/claw/new' ||
new RegExp(`^/organizations/${ORG_ROUTE_IDENTIFIER}/claw/new$`).test(pathname)
);
}

/** Extract the wastelandId from a /wasteland/[wastelandId] pathname, or null. */
Expand All @@ -33,19 +47,60 @@ function extractWastelandId(pathname: string): string | null {
return match ? match[1] : null;
}

/** Extract {orgId, wastelandId} from an /organizations/[id]/wasteland/[wastelandId] pathname, or null. */
function extractOrgWastelandId(pathname: string): { orgId: string; wastelandId: string } | null {
const match = pathname.match(new RegExp(`^/organizations/(${UUID})/wasteland/(${UUID})`));
return match ? { orgId: match[1], wastelandId: match[2] } : null;
/** Extract {orgIdentifier, wastelandId} from an /organizations/[id]/wasteland/[wastelandId] pathname, or null. */
function extractOrgWastelandId(
pathname: string
): { orgIdentifier: string; wastelandId: string } | null {
const match = pathname.match(
new RegExp(`^/organizations/(${ORG_ROUTE_IDENTIFIER})/wasteland/(${UUID})`)
);
return match ? { orgIdentifier: decodeURIComponent(match[1]), wastelandId: match[2] } : null;
}

export default function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
const currentOrgId = useUrlOrganizationId();
const trpc = useTRPC();
const currentOrgIdentifier = useUrlOrganizationIdentifier();
const pathname = usePathname();
const { open, setOpenMobile, setOpenTransient } = useSidebar();
const previousSidebarOpen = useRef<boolean | null>(null);
const currentSidebarOpen = useRef(open);
const sidebarActions = useRef({ setOpenMobile, setOpenTransient });
const { data: organizations = [] } = useQuery(
trpc.organizations.list.queryOptions(undefined, {
enabled: Boolean(currentOrgIdentifier),
trpc: {
context: {
skipBatch: true,
},
},
})
);
const currentOrgFromList = findOrganizationByRouteIdentifier(
organizations.map(org => ({
id: org.organizationId,
slug: org.organizationSlug,
})),
currentOrgIdentifier
);
const { data: resolvedCurrentOrg } = useQuery(
trpc.organizations.resolveRouteIdentifier.queryOptions(
{ routeIdentifier: currentOrgIdentifier ?? '' },
{
enabled: Boolean(currentOrgIdentifier && !currentOrgFromList),
trpc: {
context: {
skipBatch: true,
},
},
}
)
);
const currentOrgId =
currentOrgFromList?.id ??
resolvedCurrentOrg?.id ??
(currentOrgIdentifier && isUuidOrganizationRouteIdentifier(currentOrgIdentifier)
? currentOrgIdentifier
: null);

useEffect(() => {
currentSidebarOpen.current = open;
Expand Down Expand Up @@ -80,7 +135,7 @@ export default function AppSidebar(props: React.ComponentProps<typeof Sidebar>)
// Org gastown town — show the same sidebar with org-prefixed paths
const orgGastown = extractOrgGastownTownId(pathname);
if (orgGastown) {
const orgBase = `/organizations/${orgGastown.orgId}`;
const orgBase = `/organizations/${orgGastown.orgIdentifier}`;
return (
<GastownTownSidebar
townId={orgGastown.townId}
Expand All @@ -100,7 +155,7 @@ export default function AppSidebar(props: React.ComponentProps<typeof Sidebar>)
// Org wasteland — show the same sidebar with org-prefixed paths
const orgWasteland = extractOrgWastelandId(pathname);
if (orgWasteland) {
const orgBase = `/organizations/${orgWasteland.orgId}`;
const orgBase = `/organizations/${orgWasteland.orgIdentifier}`;
return (
<WastelandSidebar
wastelandId={orgWasteland.wastelandId}
Expand Down
Loading
Loading