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: 85707ad58ff21113
raw_hash: 847e872f7c9185bd
vouched: 2026-10-22T22:40:41Z
body_hash: cffc6c2ce7bd7e6d
raw_hash: 14cee7a4562529f6
vouched: 2026-10-23T12:32:53Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 85707ad58ff21113
raw_hash: 847e872f7c9185bd
vouched: 2026-10-22T22:40:41Z
body_hash: cffc6c2ce7bd7e6d
raw_hash: 14cee7a4562529f6
vouched: 2026-10-23T12:32:53Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function InitGit
created: 2026-10-22T20:44:24Z
norm: '1'
sig: bbe6f480fcc6cbd1
body_hash: 0fc448d2189f968d
raw_hash: 517902d11c496496
vouched: 2026-10-22T22:08:11Z
body_hash: bc4e9763ce66dd48
raw_hash: 9a78d51c3d8b3e6f
vouched: 2026-10-23T12:32:53Z
lines: 52-111
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function markdownRules
created: 2026-10-15T02:51:38Z
norm: '1'
sig: 0a8b81082cdfba48
body_hash: 5c98630ded958a04
raw_hash: 21ce709a783c8571
body_hash: cdce281d3c2d2b7c
raw_hash: 30247d4f6ad4c50d
vouched: 2026-10-23T12:32:53Z
lines: 221-240
---

Expand Down
35 changes: 32 additions & 3 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,15 @@ export function App() {
const linked = !!repo.value || (status === "open" && !!repo.error);
const page = repo.value === null && (saved === "issues" || saved === "prs") ? "channels" : saved;
function go(next: Page) {
if (linked || (next !== "issues" && next !== "prs")) setPage(next);
if (linked || (next !== "issues" && next !== "prs")) return setPage(next);
// ⌘3, ⌘4, and the menu reach Issues and PRs even where the rail hides them.
const what = next === "issues" ? "Issues" : "PRs";
if (!current) return void toast(`${what} need a project on GitHub`);
if (repo.value === null) {
toast(`${label(current, hello.host)} isn't on GitHub`, {
description: `${what} need a project with a GitHub remote.`,
});
}
}
const allProjects = all && page === "channels";
const visible = current
Expand Down Expand Up @@ -603,6 +611,16 @@ export function App() {
];
});
groups.unshift(pinnedGroup(channels, pins, hello, collapsed, attention, online));
// An archived channel says to unarchive it from the sidebar, so All Projects lists yours too.
const archived = channels.filter((value) =>
value.state === "archived" && value.owner === hello.user
).map((value) => row(value, hello.user, hello.host, online(value), attention.get(value.id)));
groups.push({
id: "archived",
label: "Archived",
rows: archived,
collapsed: collapsed.archived,
});
}

function chooseProject(id: string) {
Expand Down Expand Up @@ -679,8 +697,18 @@ export function App() {
if (value) remove(value);
}

/** Opening and cloning go through the host, so a shortcut while disconnected says why it can't. */
function disconnected(action: string) {
toast.error(`Can't ${action} while disconnected`, {
description: desktop
? "Ace Helper isn't connected. Open Settings → This Mac to start it."
: "Ace is reconnecting to your host.",
});
}

async function choose() {
if (!connected || picking.current) return;
if (!connected) return disconnected("open a folder");
if (picking.current) return;
if (!desktop) return setAdding(true);
picking.current = true;
try {
Expand All @@ -707,7 +735,8 @@ export function App() {
}

function cloneUrl() {
if (connected) setSetup({});
if (!connected) return disconnected("clone a repository");
setSetup({});
}

function startSetup(project: AppProject, start?: { text?: string; onCreated?: () => void }) {
Expand Down
25 changes: 20 additions & 5 deletions apps/app/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,23 +88,27 @@ const PLACES = [
},
];

/** Teammates' pulse covers what ended since this project's dashboard was last opened here. */
const FIRST_VISIT = 24 * 60 * 60_000;
/**
* Teammates' pulse covers what ended since this project's dashboard was last opened here, and at
* least the last day, so a reload or a quick look doesn't clear what someone hasn't read yet.
*/
const DAY = 24 * 60 * 60_000;

function useLastVisit(project: string): number {
const key = `ace:dashboard-visit:${host.url}:${project}`;
const [since] = useState(() => {
const day = Date.now() - DAY;
try {
return Number(localStorage.getItem(key)) || Date.now() - FIRST_VISIT;
return Math.min(Number(localStorage.getItem(key)) || day, day);
} catch {
return Date.now() - FIRST_VISIT;
return day;
}
});
useEffect(() => {
try {
localStorage.setItem(key, String(Date.now()));
} catch {
// Without storage, every visit covers the last day.
// Without storage, every visit covers only the last day.
}
}, [key]);
return since;
Expand Down Expand Up @@ -209,6 +213,16 @@ function WorkRow({ work, owner, busy, onChannel, onStart }: {
);
}

const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
const DATED = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });

/** When a run ended: the time today, with the date before that. */
function ended(at: number) {
return new Date(at).toDateString() === new Date().toDateString()
? CLOCK.format(at)
: DATED.format(at);
}

function TeammateRow({ teammate, onChannel }: {
teammate: Teammate;
onChannel: (channel: Listing) => void;
Expand All @@ -219,6 +233,7 @@ function TeammateRow({ teammate, onChannel }: {
<span key={channel.id}>
{index > 0 && ", "}
<ChannelLink channel={channel} onChannel={onChannel} />
{channel.ran && ` at ${ended(channel.ran.at)}`}
</span>
));
return (
Expand Down
5 changes: 2 additions & 3 deletions apps/app/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ export function Navigation({ page, user, github, repo, onPage, onSettings }: {
}) {
return (
<Nav
footer={
<UserMenu name={user || "Ace"} handle={github} side="right" onSettings={onSettings} />
}
// Until the host names its user, any initials would be a placeholder's.
footer={user && <UserMenu name={user} handle={github} side="right" onSettings={onSettings} />}
>
<NavList>
<NavListLink
Expand Down
26 changes: 18 additions & 8 deletions apps/app/src/open-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { IconArrowDownToLine, IconBranch, IconFolderSearch } from "@ace/ui/icons

import { desktop } from "./desktop";
import { host } from "./host";
import { Copyable } from "./team-setup";

export function EmptyProjects({ onOpen, onClone, disabled }: {
onOpen: () => void;
Expand Down Expand Up @@ -79,6 +80,10 @@ export function InitGit({ path, name, empty, ignored, disabled, onInit }: {
}
}

const folder = `'${path.replaceAll("'", `'\\''`)}'`;
const commands = `cd ${folder} && ${
empty ? "" : "git init && "
}git add -A && git commit -m "Initial commit"`;
const lanes = empty
? `Channels work in lanes, which are Git worktrees, and ${name} has no commit yet.`
: "Channels work in lanes, which are Git worktrees.";
Expand All @@ -92,14 +97,19 @@ export function InitGit({ path, name, empty, ignored, disabled, onInit }: {
</h2>
{ignored
? (
<p className="max-w-[30rem] text-pretty text-xs leading-5 text-muted-foreground">
{lanes} {name} already has a{" "}
<code>.gitignore</code>, so Ace won't commit under its rules. {empty ? "Commit" : (
<>
Run <code>git init</code> and commit
</>
)} its files yourself, then channels can start here.
</p>
<>
<p className="max-w-[30rem] text-pretty text-xs leading-5 text-muted-foreground">
{lanes} {name} already has a{" "}
<code>.gitignore</code>, so Ace won't commit under its rules. {empty ? "Commit" : (
<>
Run <code>git init</code> and commit
</>
)} its files yourself, such as in a terminal, then channels can start here.
</p>
<div className="w-full max-w-[30rem] text-left">
<Copyable value={commands} />
</div>
</>
)
: (
<>
Expand Down
21 changes: 20 additions & 1 deletion packages/ui/src/components/rich-text/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,24 @@ function codeBlockRule() {
});
}

/** Input rules for the Markdown list and inline code markers people type, as in Slack or Notion. */
/**
* `**bold**` or `~~strike~~` as its closing marker's last character is typed, which the rule
* replaces; as in Markdown, the text can't start or end with a space.
*/
function pairRule(marker: "*" | "~", mark: typeof schema.marks.strong) {
let m = `\\${marker}`;
let pattern = new RegExp(`(?:^|[^${m}])(${m}${m}([^${m}\\s](?:[^${m}]*[^${m}\\s])?)${m}${m})$`);
return new InputRule(pattern, (state, match, start, end) => {
let from = start + match[0]!.length - match[1]!.length;
return state.tr
.delete(end - 1, end)
.delete(from, from + 2)
.addMark(from, end - 3, mark.create())
.removeStoredMark(mark);
}, { inCodeMark: false });
}

/** Input rules for the Markdown markers people type, as in Slack or Notion. */
function markdownRules() {
return [
wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list),
Expand All @@ -236,6 +253,8 @@ function markdownRules() {
.addMark(from, end - 1, code.create())
.removeStoredMark(code);
}, { inCodeMark: false }),
pairRule("*", schema.marks.strong),
pairRule("~", schema.marks.strike),
];
}

Expand Down