Skip to content

Commit 53f3b30

Browse files
author
Agent
committed
chore: merge branch issue-28 with latest branding baseline
2 parents faad435 + 08104c9 commit 53f3b30

15 files changed

Lines changed: 401 additions & 7 deletions

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,22 @@ Enough time for exactly one coffee and one suspiciously calm compile.
172172
### Why does that long-running timer mention 42 minutes?
173173
174174
It is only a metaphor for persistence.
175+
176+
### Can Forkara help with non-code forks?
177+
178+
Yes. If the fork has a clear owner and a clear next step, we can still model it in our glossary.
179+
180+
### What is the difference between a Git fork and a dinner fork?
181+
182+
A Git fork tracks history and intent; a dinner fork tracks dinner and intent is still optional.
183+
184+
### Do you support pitchforks?
185+
186+
They are useful for discussions that escalate quickly, and not required for code review.
187+
188+
### Are sporks officially supported?
189+
190+
Not yet. Sporks are in the roadmap, and we keep promises in the issue tracker before the kitchen.
175191
176192
---
177193

apps/web/src/appSettings.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,20 @@ describe("environment panel defaults", () => {
262262
});
263263
});
264264

265+
describe("upstream amnesia preference", () => {
266+
it("defaults upstream hiding to off and persists the toggle as a local-only setting", () => {
267+
const defaults = AppSettingsSchema.makeUnsafe({});
268+
expect(defaults.hideUpstreamRepositoryInfo).toBe(false);
269+
270+
const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));
271+
const decoded = decode(JSON.stringify({ hideUpstreamRepositoryInfo: true }));
272+
273+
expect(normalizeStoredAppSettings(decoded)).toMatchObject({
274+
hideUpstreamRepositoryInfo: true,
275+
});
276+
});
277+
});
278+
265279
describe("resolveAppModelSelection", () => {
266280
it("preserves saved custom model slugs instead of falling back to the default", () => {
267281
expect(

apps/web/src/appSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export const AppSettingsSchema = Schema.Struct({
224224
environmentPanelDefaultOpen: Schema.Boolean.pipe(withDefaults(() => false)),
225225
showEnvironmentUsage: Schema.Boolean.pipe(withDefaults(() => true)),
226226
showEnvironmentRepository: Schema.Boolean.pipe(withDefaults(() => true)),
227+
hideUpstreamRepositoryInfo: Schema.Boolean.pipe(withDefaults(() => false)),
227228
showEnvironmentPullRequest: Schema.Boolean.pipe(withDefaults(() => true)),
228229
showEnvironmentEditor: Schema.Boolean.pipe(withDefaults(() => true)),
229230
showEnvironmentRecap: Schema.Boolean.pipe(withDefaults(() => true)),
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { getForkDenialStages } from "../lib/forkDenial";
5+
import { ForkDenialStageList } from "./ForkDenialStage";
6+
7+
describe("ForkDenialStage", () => {
8+
it("renders every stage label in provided order", () => {
9+
const stages = getForkDenialStages({ includeFinalForkState: true });
10+
const markup = renderToStaticMarkup(<ForkDenialStageList stages={stages} />);
11+
const indexes = stages.map((stage) => markup.indexOf(stage.label));
12+
13+
for (const index of indexes) {
14+
expect(index).toBeGreaterThanOrEqual(0);
15+
}
16+
expect(indexes).toEqual([...indexes].sort((a, b) => a - b));
17+
});
18+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// FILE: ForkDenialStage.tsx
2+
// Purpose: Tiny renderer for fork-denial stage copy.
3+
// Layer: UI primitives
4+
5+
import { type ForkDenialStage, getForkDenialStages } from "../lib/forkDenial";
6+
7+
export interface ForkDenialStageChipProps {
8+
stage: ForkDenialStage;
9+
}
10+
11+
export function ForkDenialStageChip({ stage }: ForkDenialStageChipProps) {
12+
return <span>{stage.label}</span>;
13+
}
14+
15+
export interface ForkDenialStageListProps {
16+
stages: readonly ForkDenialStage[];
17+
}
18+
19+
export function ForkDenialStageList({ stages }: ForkDenialStageListProps) {
20+
return (
21+
<ul>
22+
{stages.map((stage) => (
23+
<li key={stage.id}>
24+
<ForkDenialStageChip stage={stage} />
25+
</li>
26+
))}
27+
</ul>
28+
);
29+
}
30+
31+
export { getForkDenialStages };

apps/web/src/components/chat/ComposerPendingApprovalPanel.browser.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,34 @@ describe("ComposerPendingApprovalPanel", () => {
9393
}
9494
});
9595

96+
it("renders license-changer parody copy for permission requests", async () => {
97+
const mounted = await mountApprovalPanel({
98+
approval: makeApproval({
99+
requestKind: "permissions",
100+
detail: "Request to adjust LICENSE terms",
101+
permissionProfile: { license: { file: "LICENSE", readable: true }, copyrightOwner: "forkara-team" },
102+
}),
103+
});
104+
105+
try {
106+
await expect.element(
107+
page.getByText(
108+
"License Changer check: confirm legal ownership before proceeding",
109+
),
110+
).toBeInTheDocument();
111+
await expect.element(page.getByRole("link", { name: /Read current LICENSE/u })).toBeInTheDocument();
112+
await expect
113+
.element(
114+
page.getByText(
115+
"Changing license terms is not a cosmetic toggle. Verify ownership and repository policy before allowing",
116+
),
117+
)
118+
.toBeInTheDocument();
119+
} finally {
120+
await mounted.cleanup();
121+
}
122+
});
123+
96124
it("hides session approval when the provider cannot persist it", async () => {
97125
const mounted = await mountApprovalPanel({
98126
approval: makeApproval({ sessionApprovalAvailable: false }),

apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,32 @@ const KIND_PROMPT: Record<PendingApproval["requestKind"], string> = {
7676
permissions: "Grant these permissions?",
7777
};
7878

79+
const LICENSE_REFERENCE_URL = "https://github.com/redxzeta/forkara/blob/built-from-scratch/LICENSE";
80+
81+
const LICENSE_RELATED_KEYWORDS = ["license", "copyright", "copyright owner", "license_file", "license file"];
82+
83+
function hasLicenseSignals(input: { detail?: string; permissionProfile?: Record<string, unknown> }): boolean {
84+
const detail = input.detail?.toLowerCase() ?? "";
85+
const profile = input.permissionProfile;
86+
if (!detail && !profile) {
87+
return false;
88+
}
89+
if (detail && LICENSE_RELATED_KEYWORDS.some((keyword) => detail.includes(keyword))) {
90+
return true;
91+
}
92+
const profileText = profile ? JSON.stringify(profile).toLowerCase() : "";
93+
return LICENSE_RELATED_KEYWORDS.some((keyword) => profileText.includes(keyword));
94+
}
95+
7996
export const ComposerPendingApprovalPanel = function ComposerPendingApprovalPanel({
8097
approval,
8198
pendingCount,
8299
isResponding,
83100
onRespond,
84101
}: ComposerPendingApprovalPanelProps) {
85102
const parsed = parseApprovalDetail(approval.detail);
103+
const licenseProfileHint =
104+
approval.requestKind === "permissions" && hasLicenseSignals({ detail: approval.detail, permissionProfile: approval.permissionProfile });
86105
const requestId = approval.requestId;
87106
const actions =
88107
approval.sessionApprovalAvailable === false
@@ -116,7 +135,9 @@ export const ComposerPendingApprovalPanel = function ComposerPendingApprovalPane
116135
>
117136
<div className="flex items-start justify-between gap-3">
118137
<p className="min-w-0 text-[13px] font-medium leading-snug text-foreground/90">
119-
{KIND_PROMPT[approval.requestKind]}
138+
{licenseProfileHint
139+
? "License Changer check: confirm legal ownership before proceeding"
140+
: KIND_PROMPT[approval.requestKind]}
120141
{parsed.tool ? (
121142
<span className="ml-1.5 text-[11px] font-normal text-muted-foreground/50">
122143
{parsed.tool}
@@ -132,6 +153,7 @@ export const ComposerPendingApprovalPanel = function ComposerPendingApprovalPane
132153
<ApprovalDetail
133154
parsed={parsed}
134155
{...(approval.permissionProfile ? { permissionProfile: approval.permissionProfile } : {})}
156+
showLicenseChangerCopy={licenseProfileHint}
135157
/>
136158
<div className="mt-2.5 space-y-0.5">
137159
{actions.map((action, index) => (
@@ -160,11 +182,38 @@ export const ComposerPendingApprovalPanel = function ComposerPendingApprovalPane
160182
function ApprovalDetail({
161183
parsed,
162184
permissionProfile,
185+
showLicenseChangerCopy,
163186
}: {
164187
parsed: ParsedApproval;
165188
permissionProfile?: Record<string, unknown>;
189+
showLicenseChangerCopy?: boolean;
166190
}) {
167191
if (permissionProfile) {
192+
if (showLicenseChangerCopy) {
193+
return (
194+
<div className="mt-2">
195+
<p className="mb-1.5 text-[11.5px] leading-snug text-muted-foreground/70">
196+
Changing license terms is not a cosmetic toggle. Verify ownership and repository policy before allowing
197+
this, and keep the existing LICENSE as the source of truth.
198+
</p>
199+
<a
200+
href={LICENSE_REFERENCE_URL}
201+
target="_blank"
202+
rel="noreferrer"
203+
className="mb-1.5 inline-flex text-xs text-[var(--color-text-primary)] underline"
204+
>
205+
Read current LICENSE
206+
</a>
207+
<pre
208+
className="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded-md bg-[var(--color-background-elevated-secondary)] px-2.5 py-2 font-mono text-[11px] leading-relaxed text-foreground/85"
209+
title="Requested permission profile"
210+
>
211+
<code>{JSON.stringify(permissionProfile, null, 2)}</code>
212+
</pre>
213+
</div>
214+
);
215+
}
216+
168217
return (
169218
<div className="mt-2">
170219
{parsed.fallback ? (

apps/web/src/components/chat/environment/EnvironmentPanel.logic.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
12
import { describe, expect, it } from "vitest";
23

4+
import { getRepositoryLabel, UPSTREAM_AMNESIA_HINT, UPSTREAM_AMNESIA_LABEL } from "./EnvironmentPanel";
35
import { shouldShowStudioFolderRow } from "./EnvironmentPanel.logic";
46

57
describe("shouldShowStudioFolderRow", () => {
@@ -37,3 +39,30 @@ describe("shouldShowStudioFolderRow", () => {
3739
).toBe(false);
3840
});
3941
});
42+
43+
describe("getRepositoryLabel", () => {
44+
it("shows the upstream repository when amnesia is disabled", () => {
45+
const markup = renderToStaticMarkup(
46+
getRepositoryLabel({
47+
githubRepository: { nameWithOwner: "openai/codex", url: "https://github.com/openai/codex" },
48+
hideUpstreamRepositoryInfo: false,
49+
}),
50+
);
51+
52+
expect(markup).toContain("openai/codex");
53+
expect(markup).not.toContain(UPSTREAM_AMNESIA_LABEL);
54+
});
55+
56+
it("hides upstream identity with amnesia messaging when enabled", () => {
57+
const markup = renderToStaticMarkup(
58+
getRepositoryLabel({
59+
githubRepository: { nameWithOwner: "openai/codex", url: "https://github.com/openai/codex" },
60+
hideUpstreamRepositoryInfo: true,
61+
}),
62+
);
63+
64+
expect(markup).toContain(UPSTREAM_AMNESIA_LABEL);
65+
expect(markup).toContain(UPSTREAM_AMNESIA_HINT);
66+
expect(markup).not.toContain("openai/codex");
67+
});
68+
});

apps/web/src/components/chat/environment/EnvironmentPanel.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
ThreadMarkerId,
2222
} from "@synara/contracts";
2323
import { useNavigate } from "@tanstack/react-router";
24+
import { type ReactNode } from "react";
2425

2526
import { useAppSettings } from "~/appSettings";
2627
import { SETTINGS_TARGETS } from "~/settingsNavigation";
@@ -73,6 +74,31 @@ export const ENVIRONMENT_DOCKED_CONTENT_INSET_PX = 312;
7374

7475
const ENVIRONMENT_PANEL_OVERLAY_WRAPPER_CLASS_NAME =
7576
"pointer-events-none absolute inset-y-0 right-0 z-20 flex flex-col p-3";
77+
export const UPSTREAM_AMNESIA_LABEL = "Upstream information hidden";
78+
export const UPSTREAM_AMNESIA_HINT = "Upstream successfully forgotten. Git remembers.";
79+
80+
export function getRepositoryLabel(input: {
81+
githubRepository: {
82+
readonly nameWithOwner: string;
83+
readonly url: string;
84+
} | null;
85+
hideUpstreamRepositoryInfo: boolean;
86+
}): ReactNode {
87+
if (!input.hideUpstreamRepositoryInfo) {
88+
return (
89+
<span className="truncate">{input.githubRepository?.nameWithOwner ?? "Unknown repository"}</span>
90+
);
91+
}
92+
93+
return (
94+
<span className="flex min-w-0 flex-col gap-0.5">
95+
<span className="truncate text-muted-foreground">{UPSTREAM_AMNESIA_LABEL}</span>
96+
<span className="truncate text-[length:var(--app-font-size-ui-xs,10px)] text-muted-foreground/80">
97+
{UPSTREAM_AMNESIA_HINT}
98+
</span>
99+
</span>
100+
);
101+
}
76102

77103
export interface EnvironmentPanelProps {
78104
/** Drives the slide-in/out transition; the panel stays mounted so CSS can interpolate. */
@@ -387,12 +413,26 @@ export function EnvironmentPanel({
387413
<EnvironmentLabeledSection label="Repository">
388414
<EnvironmentRow
389415
icon={<GitHubIcon className={ENVIRONMENT_ROW_ICON_CLASS_NAME} aria-hidden />}
390-
label={<span className="truncate">{githubRepository.nameWithOwner}</span>}
391-
trailing={<ArrowUpRightIcon className={ENVIRONMENT_ROW_ICON_CLASS_NAME} aria-hidden />}
392-
onClick={() => {
393-
onOpenGithubRepository(githubRepository.url);
394-
onClose();
395-
}}
416+
label={getRepositoryLabel({
417+
githubRepository,
418+
hideUpstreamRepositoryInfo: settings.hideUpstreamRepositoryInfo,
419+
})}
420+
trailing={
421+
!settings.hideUpstreamRepositoryInfo ? (
422+
<ArrowUpRightIcon className={ENVIRONMENT_ROW_ICON_CLASS_NAME} aria-hidden />
423+
) : null
424+
}
425+
{...(!settings.hideUpstreamRepositoryInfo
426+
? {
427+
onClick: () => {
428+
onOpenGithubRepository(githubRepository.url);
429+
onClose();
430+
},
431+
}
432+
: {
433+
"aria-label": "Upstream repository identity is hidden",
434+
title: "Upstream repository identity is hidden",
435+
})}
396436
/>
397437
</EnvironmentLabeledSection>
398438
) : null}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { FORK_DENIAL_FINAL_STAGE, FORK_DENIAL_STAGES, getForkDenialStages, isFinalForkDenialStage } from "./forkDenial";
4+
5+
describe("forkDenial", () => {
6+
it("defines the escalation order for fork-denial copy", () => {
7+
expect(FORK_DENIAL_STAGES.map((stage) => stage.label)).toEqual([
8+
"Not a fork",
9+
"Technically not a fork",
10+
"Mostly not a fork",
11+
"Forks are normal",
12+
"The fork isn't that bad",
13+
]);
14+
expect(FORK_DENIAL_STAGES.map((stage) => stage.id)).toEqual([
15+
"not-a-fork",
16+
"technically-not-a-fork",
17+
"mostly-not-a-fork",
18+
"forks-are-normal",
19+
"fork-isnt-that-bad",
20+
]);
21+
});
22+
23+
it("supports an optional final fork state", () => {
24+
const withFinal = getForkDenialStages({ includeFinalForkState: true });
25+
26+
expect(withFinal).toHaveLength(6);
27+
expect(withFinal).toContain(FORK_DENIAL_FINAL_STAGE);
28+
expect(isFinalForkDenialStage(withFinal[5]!)).toBe(true);
29+
});
30+
31+
it("keeps default stages limited to the core satire ladder", () => {
32+
const core = getForkDenialStages();
33+
expect(core).toHaveLength(5);
34+
expect(core).toEqual(FORK_DENIAL_STAGES);
35+
});
36+
});

0 commit comments

Comments
 (0)