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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 726ff380a6ed89cc
raw_hash: c03dc81b6aead86a
vouched: 2026-10-15T04:38:31Z
body_hash: 0d7b1cb6c65605d5
raw_hash: b2fe7bdf13815593
vouched: 2026-10-15T07:04:36Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function projects
created: 2026-10-15T02:51:27Z
norm: '1'
sig: a2ec110fa31d81ee
body_hash: 98fe298db41971b4
raw_hash: b3ec10e5a33485c2
body_hash: 708f362a469da056
raw_hash: ba42a00a04cb6a48
vouched: 2026-10-15T07:04:36Z
lines: 42-91
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4Z60Y09MP3TBA2DNWYT5040
anchor: function disambiguate
created: 2026-10-15T07:04:31Z
norm: '1'
sig: 4630d200c7169777
body_hash: 92ca5d5d10b93078
raw_hash: 6ee4f974880d7307
lines: 52-79
---

labels are computed over the whole project list after merging by identity: Git worktrees already join their main checkout and GitHub checkouts merge by repo, so only distinct projects can collide
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4Z60Y5ZWGMZ6EXEHA0CP4BP
anchor: function disambiguate
created: 2026-10-15T07:04:32Z
norm: '1'
sig: 4630d200c7169777
body_hash: 92ca5d5d10b93078
raw_hash: 6ee4f974880d7307
lines: 52-79
---

labels are computed over the full merged project list so every surface shows the same distinct name
13 changes: 8 additions & 5 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
label,
projectId,
projects,
title,
} from "./projects";
import { Settings } from "./settings";
import { SetupProject } from "./setup-project";
Expand Down Expand Up @@ -345,6 +346,7 @@ export function App() {
id: value.id,
name: label(value, hello.host),
org: value.repo?.split("/")[0] || "",
title: title(value, hello.host),
})),
];
const { attention, pulse } = useAttention(
Expand Down Expand Up @@ -470,6 +472,7 @@ export function App() {
const target: AppProject = {
...value,
id,
label: value.name,
host: hello.host,
reachable: true,
hosts: new Set([hello.host]),
Expand Down Expand Up @@ -616,7 +619,7 @@ export function App() {
? (
<ChannelsSidebar
className="min-h-0 w-full min-w-0 flex-1 bg-transparent"
projectName={allProjects ? "All Projects" : current.name}
projectName={allProjects ? "All Projects" : current.label}
repos={repos}
selectedRepoId={allProjects ? ALL_PROJECTS : current.id}
groups={groups}
Expand Down Expand Up @@ -744,7 +747,7 @@ export function App() {
<h1 className="text-base font-medium">
{allProjects
? "Choose a channel from any project"
: `Choose a channel in ${current.name}`}
: `Choose a channel in ${current.label}`}
</h1>
{allProjects && (
<p className="text-sm text-muted-foreground">
Expand All @@ -753,7 +756,7 @@ export function App() {
)}
{!allProjects && !local && (
<p className="max-w-[34rem] text-sm text-muted-foreground">
{current.name} is checked out on {[...current.hosts].join(", ")}.{" "}
{current.label} is checked out on {[...current.hosts].join(", ")}.{" "}
{current.repo
? `Set it up on ${here} to start channels here.`
: "Open a checkout on this host to start channels here."}
Expand All @@ -762,7 +765,7 @@ export function App() {
{!allProjects && needsGit && (
<div className="w-full max-w-[34rem]">
<InitGit
name={current.name}
name={current.label}
disabled={!connected}
onInit={() => init(current.path)}
/>
Expand Down Expand Up @@ -797,7 +800,7 @@ export function App() {
attention={attention}
onChannel={select}
onCreate={current && creatable && connected
? { name: current.name, run: () => void create() }
? { name: current.label, run: () => void create() }
: undefined}
/>
{adding && <OpenProject onOpen={open} onClose={() => setAdding(false)} />}
Expand Down
10 changes: 5 additions & 5 deletions apps/app/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,21 @@ export function Dashboard(
trigger={
<button
type="button"
aria-label={`Select project, currently ${project.name}`}
aria-label={`Select project, currently ${project.label}`}
className="group inline-flex max-w-full select-none items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-[background-color,color,box-shadow] hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
/>
}
>
<span className="truncate">{project.name}</span>
<span className="truncate">{project.label}</span>
<IconChevronDown className="size-3 shrink-0" aria-hidden />
</ProjectPicker>
<h1 className="text-balance text-display-xs leading-[1.2] font-medium tracking-tight sm:text-display-sm lg:text-display">
{greeting}
</h1>
<p className="max-w-[34rem] text-pretty text-sm leading-[1.55] text-foreground/65">
{local
? `What would you like to work on in ${project.name}?`
: `Catch up on ${project.name}’s channels on ${[...project.hosts].join(", ")}. ${
? `What would you like to work on in ${project.label}?`
: `Catch up on ${project.label}’s channels on ${[...project.hosts].join(", ")}. ${
onSetup
? `Set it up on ${here} to start channels here.`
: "Open a checkout on this host to start channels here."
Expand All @@ -139,7 +139,7 @@ export function Dashboard(
</section>
{needsGit && (
<div className="mx-auto mt-9 max-w-[39rem]">
<InitGit name={project.name} disabled={!connected} onInit={onInit} />
<InitGit name={project.label} disabled={!connected} onInit={onInit} />
</div>
)}
{creatable && (
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/github.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function GithubSidebar({ kind, project, repos, connected, onProject, onOp
return (
<ProjectSidebar
className="min-h-0 w-full min-w-0 flex-1 bg-transparent"
projectName={project.name}
projectName={project.label}
repos={repos}
selectedRepoId={project.id}
onRepoChange={(repo) => onProject(repo.id)}
Expand Down
54 changes: 48 additions & 6 deletions apps/app/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import type { Listing, Project } from "@ace/host/protocol";
* `host` and `path` are the checkout that creates channels and reads GitHub: this host's own
* checkout when it has one, else one whose host can answer for it. `reachable` is whether it can:
* a peer answers only for a project its own listing shows a live channel in.
* `checkouts` holds every known checkout's ID on any host.
* `checkouts` holds every known checkout's ID on any host. `label` is `name`, unique among
* projects shown alike.
*/
export type AppProject = Project & {
id: string;
label: string;
host: string;
reachable: boolean;
hosts: Set<string>;
Expand All @@ -34,10 +36,47 @@ export const channelProject = (channel: Pick<Listing, "host" | "project" | "root
* A GitHub project is the same project wherever it is checked out. A folder without one is only
* its host's, so a folder elsewhere is named by the hosts that have it.
*/
export const label = (project: AppProject, host: string) =>
project.repo || project.host === host
? project.name
: `${project.name} · ${[...project.hosts].join(", ")}`;
const hosts = (project: AppProject, host: string) =>
project.repo || project.host === host ? "" : ` · ${[...project.hosts].join(", ")}`;

export const label = (project: AppProject, host: string) => project.label + hosts(project, host);

/** The full checkout behind a label, for hover. */
export const title = (project: AppProject, host: string) =>
project.host === host ? project.path : `${project.path} on ${project.host}`;

/**
* Like VS Code's editor tabs, projects shown alike gain the fewest trailing parent folders that
* tell them apart, or the owner for a GitHub project, whose checkout is not its identity.
*/
function disambiguate(values: AppProject[], host: string) {
const alike = new Map<string, AppProject[]>();
for (const value of values) {
const key = value.name + hosts(value, host);
const list = alike.get(key);
if (list) list.push(value);
else alike.set(key, [value]);
}
for (const list of alike.values()) {
if (list.length < 2) continue;
const parents = list.map((value) =>
value.repo
? [value.repo.split("/")[0]!]
: value.path.replace(/\/+$/, "").split("/").slice(0, -1).filter(Boolean)
);
list.forEach((value, index) => {
const own = parents[index]!;
let count = 1;
while (
count < own.length
&& parents.some((other, at) =>
at !== index && other.slice(-count).join("/") === own.slice(-count).join("/")
)
) count++;
value.label = `${value.name} — ${own.slice(-count).join("/") || "/"}`;
});
}
}

export function projects(opened: Project[], channels: Listing[], host: string): AppProject[] {
const values = new Map<string, AppProject>();
Expand All @@ -59,6 +98,7 @@ export function projects(opened: Project[], channels: Listing[], host: string):
host: at,
path,
name,
label: name,
reachable,
hosts: new Set([at]),
checkouts: new Set([checkout]),
Expand Down Expand Up @@ -87,5 +127,7 @@ export function projects(opened: Project[], channels: Listing[], host: string):
|| !channel.hosted && (channel.state === "running" || channel.state === "dormant");
add(channel.host, path, name, reachable, channel.repo);
}
return [...values.values()];
const found = [...values.values()];
disambiguate(found, host);
return found;
}
4 changes: 2 additions & 2 deletions apps/app/src/setup-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Set up {project.name} on {here}</DialogTitle>
<DialogTitle>Set up {project.label} on {here}</DialogTitle>
<DialogDescription>
{project.name} is checked out on {[...project.hosts].join(", ")}. Clone {project.repo}
{project.label} is checked out on {[...project.hosts].join(", ")}. Clone {project.repo}
{" "}
into a folder on {here}
{starting ? " to start the channel here." : "."}{" "}
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ keeps that grouped view; channel creation and GitHub pages retain a concrete pro
The app identifies a project with a GitHub remote by its repository, compared case-insensitively,
so checkouts of it on any host are one project; a folder without one is its host and main checkout.
A project keeps every known checkout. A GitHub project shows its name wherever it is checked out;
a folder without one on another host is labeled with that host. Creating channels and archiving
a folder without one on another host is labeled with that host. Projects that would show the same
label add the fewest trailing parent folders that tell them apart, or a GitHub project its owner, and
the project picker shows each checkout's full path on hover. Creating channels and archiving
inactive ones use this host's checkout. A GitHub project checked out only on teammates' hosts lists
their channels, and its channel creation entry points first offer to set it up on this machine:
the person confirms or edits a destination, `~/code/<repo>` by default, before anything is cloned.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export type SessionSidebarRepo = {
name: string;
org: string;
avatar?: string;
/** Shown on hover, such as the project's full path. */
title?: string;
};

export type SessionSidebarProps = Omit<ComponentPropsWithRef<"aside">, "children" | "onSelect"> & {
Expand Down Expand Up @@ -144,7 +146,7 @@ function RepoAvatar({ repo, className }: { repo: SessionSidebarRepo; className?:
);
}

function RepoName({ name }: { name: string }) {
function RepoName({ name, title }: { name: string; title?: string }) {
let root = useRef<HTMLSpanElement>(null);
let [label, setLabel] = useState(name);

Expand All @@ -169,7 +171,7 @@ function RepoName({ name }: { name: string }) {
return (
<span ref={root} className="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-medium">
<span
title={label === name ? undefined : name}
title={title || (label === name ? undefined : name)}
className="inline-block max-w-full align-bottom"
>
{label}
Expand Down Expand Up @@ -205,7 +207,7 @@ function RepoRow({ repo, selected, onRepoChange, onRepoRemove }: {
onRepoRemove(repo);
}}
>
<RepoName name={repo.name} />
<RepoName name={repo.name} title={repo.title} />
<span className="relative grid size-5 shrink-0 place-items-center">
{selected && (
<IconCheck className="size-3.5 transition-opacity duration-150 ease-out group-hover/repo:opacity-0 group-focus/repo:opacity-0 group-focus-within/repo:opacity-0" />
Expand Down Expand Up @@ -276,6 +278,7 @@ export function ProjectPicker(
return (
<DropdownMenu>
<DropdownMenuTrigger
title={selected.title}
render={trigger || (
<Button
variant="ghost"
Expand Down