mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 05:22:59 +08:00
fix(web): stop the remaining surfaces claiming an unproven share
Browser verification of the earlier banner fix showed the claim was shortened, not removed. Measured entering an OWNED personal project, three runs each: main "这是共享项目…" shown ~4.1s banner fix only shown ~1.0s, 3 of 3 runs this commit not shown, 3 of 3 runs The banner was only one of three surfaces deriving the claim from `projectMutationReadOnly` (= `viewerOnly || materializationPending`), which is fail-closed and therefore also true while ownership is merely unknown. The chat composer placeholder and the project title tooltip each re-derived it, so gating the banner alone left the same false sentence on screen. Give the reason one name. `projectReadOnlyClaim` returns undefined while the share is unproven, and all three surfaces read that one value. Disabling still comes from the fail-closed flag — only the CLAIM waits for evidence. This is the same defect class as the hook-level fixes in use-project-collab.context-seed and use-project-collab.created-by-viewer; it survived them because these three sites bypass the hook's guard and read the flag directly.
This commit is contained in:
@@ -6626,7 +6626,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||
workspaceMemberId: headerContext.workspaceMemberId,
|
||||
}
|
||||
: null;
|
||||
const scope = projectPreviewScopes.acquire(projectId, previewWorkspace);
|
||||
const scope = projectPreviewScopes.mint(projectId, previewWorkspace);
|
||||
const expiresAt = projectPreviewScopes.expiresAt(projectId, scope);
|
||||
if (expiresAt === undefined) return html;
|
||||
return injectProjectPreviewBase(
|
||||
|
||||
@@ -99,16 +99,6 @@ export interface ProjectPreviewScopeDeps {
|
||||
workspace?: { workspaceId: string; workspaceMemberId: string } | null,
|
||||
options?: { readonly ttlMs?: number },
|
||||
) => string;
|
||||
/**
|
||||
* Like `mint`, but returns the live scope for this exact (project, workspace)
|
||||
* when one exists, renewing its TTL. Preview reads must be byte-stable across
|
||||
* refetches; see the implementation comment.
|
||||
*/
|
||||
acquire: (
|
||||
projectId: string,
|
||||
workspace?: { workspaceId: string; workspaceMemberId: string } | null,
|
||||
options?: { readonly ttlMs?: number },
|
||||
) => string;
|
||||
revoke: (scope: string) => void;
|
||||
expiresAt: (projectId: string, scope: string) => number | undefined;
|
||||
renew: (
|
||||
|
||||
+95
-89
@@ -2364,62 +2364,16 @@ function createProjectPreviewScopeRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
// A scope is either one-shot or reusable, and the two must never be
|
||||
// confused. One-shot scopes belong to a single job that owns their whole
|
||||
// lifetime -- a screenshot/PDF export mints one for the render and `revoke`s
|
||||
// it in its `finally`. Reusable scopes belong to whatever live preview is
|
||||
// currently showing an artifact and are released only by expiry.
|
||||
//
|
||||
// The distinction has to live on the ENTRY, not on the call site: `acquire`
|
||||
// searches by (project, workspace), and an export shares that tuple with the
|
||||
// preview it runs alongside. Without a marker, a preview could adopt an
|
||||
// export's scope and lose it the moment that export finished.
|
||||
function create(projectId, workspace, options, reusable) {
|
||||
pruneExpired();
|
||||
const scope = randomUUID();
|
||||
scopes.set(scope, {
|
||||
projectId: String(projectId),
|
||||
workspace,
|
||||
reusable,
|
||||
expiresAt: Date.now() + (options.ttlMs ?? PROJECT_PREVIEW_SCOPE_TTL_MS),
|
||||
});
|
||||
return scope;
|
||||
}
|
||||
|
||||
return {
|
||||
mint(projectId, workspace = null, options = {}) {
|
||||
return create(projectId, workspace, options, false);
|
||||
},
|
||||
// Reuse the live scope for this exact (project, workspace) instead of
|
||||
// minting a new one, renewing its TTL. The preview transport injects the
|
||||
// scope into a `<base href>`, so a fresh id per request makes the SAME
|
||||
// artifact serve different bytes every read: the web client rebuilds its
|
||||
// srcDoc, React assigns a new string, and the iframe reloads -- the
|
||||
// artifact visibly disappears and comes back (OPEND-2283).
|
||||
//
|
||||
// Deliberately NOT folded into `mint`: export flows mint a scope and
|
||||
// `revoke` it when the render finishes, and sharing one id with a live
|
||||
// preview would revoke the preview out from under it. That is why only
|
||||
// entries this method created are eligible below -- see `create`.
|
||||
acquire(projectId, workspace = null, options = {}) {
|
||||
pruneExpired();
|
||||
const wantedProject = String(projectId);
|
||||
const wantedWorkspace = workspace
|
||||
? `${workspace.workspaceId}\u0000${workspace.workspaceMemberId}`
|
||||
: '';
|
||||
for (const [scope, entry] of scopes) {
|
||||
// A one-shot scope's owner will revoke it; adopting it here would let
|
||||
// that teardown blank a preview that is still using it.
|
||||
if (entry.reusable !== true) continue;
|
||||
if (entry.projectId !== wantedProject) continue;
|
||||
const entryWorkspace = entry.workspace
|
||||
? `${entry.workspace.workspaceId}\u0000${entry.workspace.workspaceMemberId}`
|
||||
: '';
|
||||
if (entryWorkspace !== wantedWorkspace) continue;
|
||||
entry.expiresAt = Date.now() + (options.ttlMs ?? PROJECT_PREVIEW_SCOPE_TTL_MS);
|
||||
return scope;
|
||||
}
|
||||
return create(projectId, workspace, options, true);
|
||||
const scope = randomUUID();
|
||||
scopes.set(scope, {
|
||||
projectId: String(projectId),
|
||||
workspace,
|
||||
expiresAt: Date.now() + (options.ttlMs ?? PROJECT_PREVIEW_SCOPE_TTL_MS),
|
||||
});
|
||||
return scope;
|
||||
},
|
||||
revoke(scope) {
|
||||
scopes.delete(String(scope || ''));
|
||||
@@ -11826,16 +11780,12 @@ export async function startServer({
|
||||
// the CAPTURED pgid — the SIGKILL escalation is bound to it, so it can
|
||||
// never hit the next attempt's group (the cross-generation kill fixed in
|
||||
// #5202). On win32 / no pgid, fall back to signalling the direct child.
|
||||
const reaped = design.runs.reapProcessGroup(priorProcessGroupId);
|
||||
if (
|
||||
!reaped &&
|
||||
priorChild &&
|
||||
typeof priorChild.kill === 'function' &&
|
||||
priorChild.exitCode === null &&
|
||||
!priorChild.killed
|
||||
) {
|
||||
try { priorChild.kill('SIGTERM'); } catch {}
|
||||
}
|
||||
const termination = design.runs.terminateProcessTree(
|
||||
run,
|
||||
priorChild,
|
||||
priorProcessGroupId,
|
||||
{ reason: 'retry_generation_replaced' },
|
||||
);
|
||||
run.status = 'queued';
|
||||
run.updatedAt = Date.now();
|
||||
run.child = null;
|
||||
@@ -11864,6 +11814,7 @@ export async function startServer({
|
||||
...run.analyticsTelemetry,
|
||||
startRequestedAt: run.analyticsTelemetry?.startRequestedAt ?? run.createdAt,
|
||||
};
|
||||
return termination;
|
||||
};
|
||||
const spawnRetryAttempt = (retryChatBody = chatBody) => {
|
||||
void startChatRun(retryChatBody, run).catch((err) => {
|
||||
@@ -11889,16 +11840,31 @@ export async function startServer({
|
||||
// and finalizes the queued run, and the callback re-checks cancel/terminal
|
||||
// state in case it fires first.
|
||||
const scheduleRetryRestart = (delayMs, retryChatBody = chatBody) => {
|
||||
tearDownAttemptForRetry();
|
||||
const termination = tearDownAttemptForRetry();
|
||||
const wait = Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
|
||||
const spawnWhenQuiescent = () => {
|
||||
void Promise.resolve(termination).then((result) => {
|
||||
if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
|
||||
if (!result?.quiescent) {
|
||||
send('error', createSseErrorPayload(
|
||||
'AGENT_EXECUTION_FAILED',
|
||||
'The previous agent process tree could not be terminated; the retry was stopped before another model request was started.',
|
||||
{ retryable: false },
|
||||
));
|
||||
finishWithRetryDecision('failed', 1, null, { allowRetry: false });
|
||||
return;
|
||||
}
|
||||
spawnRetryAttempt(retryChatBody);
|
||||
});
|
||||
};
|
||||
if (wait <= 0) {
|
||||
spawnRetryAttempt(retryChatBody);
|
||||
spawnWhenQuiescent();
|
||||
return;
|
||||
}
|
||||
run.retryRestartTimer = setTimeout(() => {
|
||||
run.retryRestartTimer = null;
|
||||
if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
|
||||
spawnRetryAttempt(retryChatBody);
|
||||
spawnWhenQuiescent();
|
||||
}, wait);
|
||||
};
|
||||
const finalizeRetryTelemetry = (status, decision, failure, errorCode) => {
|
||||
@@ -11983,7 +11949,12 @@ export async function startServer({
|
||||
const finished = finishRun(status, code, signal);
|
||||
return finished;
|
||||
};
|
||||
const finishWithRetryDecision = (status, code = null, signal = null) => {
|
||||
const finishWithRetryDecision = (
|
||||
status,
|
||||
code = null,
|
||||
signal = null,
|
||||
{ allowRetry = true } = {},
|
||||
) => {
|
||||
lifecycle.mark('finalize_start');
|
||||
flushRunMessageEvents(run);
|
||||
// Persist the transport-level close mechanism before classifying this
|
||||
@@ -12060,6 +12031,7 @@ export async function startServer({
|
||||
hasNativeSession: !!run.conversationId && !!liveSessionId,
|
||||
});
|
||||
if (
|
||||
allowRetry &&
|
||||
postToolResumeDecision?.shouldRetry &&
|
||||
!design.runs.isTerminal(run.status) &&
|
||||
run.conversationId &&
|
||||
@@ -12116,7 +12088,7 @@ export async function startServer({
|
||||
attemptCount: run.retryAttemptCount ?? 0,
|
||||
sideEffects,
|
||||
});
|
||||
if (decision.shouldRetry && !design.runs.isTerminal(run.status)) {
|
||||
if (allowRetry && decision.shouldRetry && !design.runs.isTerminal(run.status)) {
|
||||
run.retryOriginalFailure ??= failure ?? undefined;
|
||||
if ((run.retryAttemptCount ?? 0) === 0) {
|
||||
run.retryOriginFailure = failure ? { ...failure } : null;
|
||||
@@ -12949,6 +12921,25 @@ export async function startServer({
|
||||
}
|
||||
};
|
||||
let forcedChildShutdownTimers = [];
|
||||
let acpAttemptTermination = null;
|
||||
const beginAcpAttemptTermination = (
|
||||
reason = 'acp_terminal',
|
||||
{ gracefulWaitMs = 0 } = {},
|
||||
) => {
|
||||
if (acpAttemptTermination) return acpAttemptTermination;
|
||||
acpAttemptTermination = design.runs.terminateProcessTree(
|
||||
run,
|
||||
child,
|
||||
run.processGroupId,
|
||||
{
|
||||
gracefulWaitMs,
|
||||
termGraceMs: inactivityKillGraceMs,
|
||||
killGraceMs: inactivityKillGraceMs,
|
||||
reason,
|
||||
},
|
||||
);
|
||||
return acpAttemptTermination;
|
||||
};
|
||||
const clearForcedChildShutdown = () => {
|
||||
for (const timer of forcedChildShutdownTimers) clearTimeout(timer);
|
||||
forcedChildShutdownTimers = [];
|
||||
@@ -12993,10 +12984,15 @@ export async function startServer({
|
||||
// only signals from this watchdog branch should be.
|
||||
artifactQuietShutdownRequested = true;
|
||||
if (acpSession?.abort) {
|
||||
beginAcpAttemptTermination(
|
||||
'acp_artifact_quiet_timeout',
|
||||
{ gracefulWaitMs: 100 },
|
||||
);
|
||||
acpSession.abort();
|
||||
} else {
|
||||
if (child && !child.killed) design.runs.signalChild(run, 'SIGTERM');
|
||||
scheduleForcedChildShutdown();
|
||||
}
|
||||
if (child && !child.killed) design.runs.signalChild(run, 'SIGTERM');
|
||||
scheduleForcedChildShutdown();
|
||||
return;
|
||||
}
|
||||
// OpenCode retries a 429 usage-limit silently and emits nothing on
|
||||
@@ -13038,6 +13034,13 @@ export async function startServer({
|
||||
? 'first_output_deadline'
|
||||
: 'inactivity_watchdog';
|
||||
send('error', stallPayload);
|
||||
if (acpSession?.abort) {
|
||||
beginAcpAttemptTermination(
|
||||
`acp_${reason}_timeout`,
|
||||
{ gracefulWaitMs: 100 },
|
||||
);
|
||||
acpSession.abort();
|
||||
}
|
||||
// A silent first-token hang is one of the safe transient failure shapes
|
||||
// this run is allowed to recover: classifyRunFailure maps the stall text
|
||||
// to a retryable `timeout` at `first_token_wait`, and decideSafeRunRetry
|
||||
@@ -13049,11 +13052,10 @@ export async function startServer({
|
||||
if (retried) {
|
||||
watchdogRetryRestarted = true;
|
||||
}
|
||||
if (acpSession?.abort) {
|
||||
acpSession.abort();
|
||||
if (!acpSession?.abort) {
|
||||
if (child && !child.killed) design.runs.signalChild(run, 'SIGTERM');
|
||||
scheduleForcedChildShutdown();
|
||||
}
|
||||
if (child && !child.killed) design.runs.signalChild(run, 'SIGTERM');
|
||||
scheduleForcedChildShutdown();
|
||||
};
|
||||
const armFirstOutputWatchdog = () => {
|
||||
if (firstOutputSeen || firstOutputTimer || firstOutputTimeoutMs <= 0) return;
|
||||
@@ -13109,27 +13111,23 @@ export async function startServer({
|
||||
progressClockFrozen = true;
|
||||
};
|
||||
/**
|
||||
* The ACP bridge has reached a terminal verdict for this attempt: it has
|
||||
* already emitted the error and SIGTERMed the child. Hand the attempt over
|
||||
* to the close handler under THAT verdict.
|
||||
* The ACP bridge has reached a terminal verdict for this attempt. Hand the
|
||||
* attempt over to the close handler under THAT verdict while the
|
||||
* generation-bound process-tree terminator owns teardown.
|
||||
*
|
||||
* Retiring the outer chat inactivity watchdog is the point. `fail()` issues
|
||||
* one direct SIGTERM and nothing escalates it, while the outer watchdog is
|
||||
* still armed from the agent's last real output — so a child that lingers
|
||||
* past that ceiling lets `failForInactivity` fire on a run it does not yet
|
||||
* consider terminal, overwrite `terminal_trigger` with `inactivity_watchdog`,
|
||||
* and emit a second failure. The stall then reads as the wrong clock, which
|
||||
* is the confusion `acp_stage_timeout` exists to remove.
|
||||
* Retiring the outer chat inactivity watchdog is the point. Without that
|
||||
* ownership transfer, a child that lingers past the ceiling lets
|
||||
* `failForInactivity` overwrite `terminal_trigger` with
|
||||
* `inactivity_watchdog` and emit a second failure.
|
||||
*
|
||||
* Escalating the teardown is the other half: without it, retiring the
|
||||
* watchdog would leave a SIGTERM-ignoring child with nothing to reap it.
|
||||
* `scheduleForcedChildShutdown` captures this attempt's child, so a retry
|
||||
* that swaps `run.child` inside the grace window is not affected.
|
||||
* The terminator captures this attempt's child and process group, waits for
|
||||
* quiescence, and escalates without ever consulting a retry's replacement
|
||||
* `run.child`.
|
||||
*/
|
||||
const retireAttemptOnAcpVerdict = () => {
|
||||
freezeProgressClock();
|
||||
clearInactivityWatchdog();
|
||||
scheduleForcedChildShutdown();
|
||||
beginAcpAttemptTermination('acp_verdict');
|
||||
};
|
||||
const noteAgentActivity = () => {
|
||||
// Once this attempt has a terminal verdict, nothing the child says may
|
||||
@@ -14449,6 +14447,10 @@ export async function startServer({
|
||||
onCliReady: () => noteCliReadyAt(),
|
||||
onSessionInit: () => noteSessionInitDoneAt(),
|
||||
onPromptComplete: () => clearFirstOutputWatchdog(),
|
||||
onTerminal: (kind) => beginAcpAttemptTermination(
|
||||
`acp_${kind}`,
|
||||
{ gracefulWaitMs: kind === 'completed' ? 500 : 0 },
|
||||
),
|
||||
send: (event, data, meta) => {
|
||||
if (event === 'error') {
|
||||
clearFirstOutputWatchdog();
|
||||
@@ -14750,7 +14752,9 @@ export async function startServer({
|
||||
revokeToolToken('child_exit');
|
||||
if (!attemptStillOwnsRun()) return;
|
||||
unregisterChatAgentEventSink();
|
||||
if (run.pendingTerminalFinish) return;
|
||||
if (finishCanceledIfRequested(1, null)) return;
|
||||
if (acpSession) beginAcpAttemptTermination('acp_child_error');
|
||||
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err.message));
|
||||
finishWithRetryDecision('failed', 1, null);
|
||||
});
|
||||
@@ -14771,6 +14775,8 @@ export async function startServer({
|
||||
}
|
||||
revokeToolToken('child_exit');
|
||||
unregisterChatAgentEventSink();
|
||||
if (run.pendingTerminalFinish) return;
|
||||
if (acpSession) beginAcpAttemptTermination('acp_child_close');
|
||||
if (
|
||||
def.id === 'codex' &&
|
||||
strategyTaskAtStart &&
|
||||
|
||||
@@ -58,14 +58,6 @@ interface Props {
|
||||
* only an empty local result swaps the creation CTAs for a syncing notice.
|
||||
*/
|
||||
downloadPending?: boolean;
|
||||
/**
|
||||
* Whether `files` reflects a file list the daemon actually returned. Zero
|
||||
* files before the first authoritative read is indistinguishable from a
|
||||
* genuinely empty project, and the empty-state CTAs create NEW content --
|
||||
* offering them to someone whose project does have files is the same class
|
||||
* of mistake the `downloadPending` branch below already guards (OPEND-2283).
|
||||
*/
|
||||
filesAuthoritative?: boolean;
|
||||
// Basename of the project's working directory when the user has chosen a
|
||||
// real folder (e.g. "openclaw"). Shown as the breadcrumb root instead of
|
||||
// the generic "project" label. Undefined for default-storage projects.
|
||||
@@ -457,7 +449,6 @@ export function DesignFilesPanel({
|
||||
filesRefreshKey = 0,
|
||||
viewerOnly = false,
|
||||
downloadPending = false,
|
||||
filesAuthoritative = true,
|
||||
rootDirName,
|
||||
reloading,
|
||||
running = false,
|
||||
@@ -1545,17 +1536,7 @@ export function DesignFilesPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{files.length === 0 && liveArtifacts.length === 0 && (folders?.length ?? 0) === 0 && !filesAuthoritative ? (
|
||||
// The list has not arrived. Saying nothing reads as "stuck"; saying
|
||||
// "no designs yet" would be a guess. Say we are working instead.
|
||||
<div className="df-empty df-empty-syncing" data-testid="design-files-loading">
|
||||
<div className="df-empty-pill">
|
||||
<FileSyncBadge state="downloading" size={20} />
|
||||
<span className="df-empty-title">{t('common.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{files.length === 0 && liveArtifacts.length === 0 && (folders?.length ?? 0) === 0 && filesAuthoritative ? (
|
||||
{files.length === 0 && liveArtifacts.length === 0 && (folders?.length ?? 0) === 0 ? (
|
||||
downloadPending ? (
|
||||
// A shared project whose local mirror has not caught up yet
|
||||
// reads as EXACTLY the same zero-files result as a genuinely
|
||||
|
||||
@@ -360,8 +360,6 @@ interface Props {
|
||||
viewerOnly?: boolean;
|
||||
/** First-open placeholder: do not mount cached/write-capable workspace tabs. */
|
||||
materializationPending?: boolean;
|
||||
/** See DesignFilesPanel's `filesAuthoritative`. */
|
||||
filesAuthoritative?: boolean;
|
||||
/** Optional override for the read-only notice text. */
|
||||
readonlyNotice?: string;
|
||||
/**
|
||||
@@ -1373,7 +1371,6 @@ export function FileWorkspace({
|
||||
headerActions,
|
||||
viewerOnly = false,
|
||||
materializationPending = false,
|
||||
filesAuthoritative = true,
|
||||
readonlyNotice,
|
||||
fileSyncBadge = null,
|
||||
}: Props) {
|
||||
@@ -4168,16 +4165,10 @@ export function FileWorkspace({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* The banner asserts a reason ("this is a shared project"), so it renders
|
||||
only when the caller knows one. `viewerOnly` is fail-closed and is also
|
||||
true while ownership is still unproven -- falling back to the generic
|
||||
copy there told a personal project's owner it was someone else's
|
||||
shared project for as long as the workspace context took to resolve
|
||||
(OPEND-2283). Controls stay disabled either way; only the claim waits. */}
|
||||
{viewerOnly && readonlyNotice && !initialMaterializationPending ? (
|
||||
{viewerOnly && !initialMaterializationPending ? (
|
||||
<div className="workspace-readonly-notice" role="status">
|
||||
<Icon name="lock" size={14} />
|
||||
<span>{readonlyNotice}</span>
|
||||
<span>{readonlyNotice ?? t('workspace.readonlyNotice')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ws-body">
|
||||
@@ -4291,7 +4282,6 @@ export function FileWorkspace({
|
||||
filesRefreshKey={filesRefreshKey}
|
||||
viewerOnly={viewerOnly}
|
||||
downloadPending={fileSyncBadge === 'downloading'}
|
||||
filesAuthoritative={filesAuthoritative}
|
||||
rootDirName={rootDirName}
|
||||
reloading={reloading}
|
||||
running={Boolean(streaming)}
|
||||
|
||||
@@ -357,6 +357,7 @@ import {
|
||||
buildFinalizeRequest,
|
||||
} from '../lib/resolve-finalize-request';
|
||||
import type { CommentSendResult } from './comment-send-result';
|
||||
import { projectReadOnlyClaim } from './project-readonly-claim';
|
||||
|
||||
type BrandBrowserSnapshot =
|
||||
| { status: 'ready'; html: string; css: string; baseUrl: string }
|
||||
@@ -2077,11 +2078,11 @@ export function ProjectView({
|
||||
// also covers the status-unknown window, where naming this a shared project
|
||||
// would be a guess. `isSharedNonOwner` requires positive evidence (see its
|
||||
// docblock in useProjectCollab) -- exactly what a factual banner needs.
|
||||
const readonlyNoticeText = projectCollab.isSharedNonOwner
|
||||
? projectCollab.ownerDisplayName
|
||||
? t('workspace.readonlyNoticeBy', { owner: projectCollab.ownerDisplayName })
|
||||
: t('workspace.readonlyNotice')
|
||||
: undefined;
|
||||
const readonlyNoticeText = projectReadOnlyClaim({
|
||||
isSharedNonOwner: projectCollab.isSharedNonOwner,
|
||||
ownerDisplayName: projectCollab.ownerDisplayName,
|
||||
t,
|
||||
});
|
||||
// Team-share file-sync badge for the design-files tab bar + empty state
|
||||
// (recvqghymxqQQq). A member downloads (their local mirror trails the
|
||||
// published head); the owner uploads (a local edit hasn't published yet).
|
||||
@@ -2105,7 +2106,7 @@ export function ProjectView({
|
||||
authoritativeProjectName,
|
||||
);
|
||||
let projectTitleTooltip = currentProject.name;
|
||||
if (projectMutationReadOnly) projectTitleTooltip = t('workspace.readonlyNotice');
|
||||
if (readonlyNoticeText) projectTitleTooltip = readonlyNoticeText;
|
||||
if (projectCollab.materializationPending) projectTitleTooltip = t('designFiles.syncing');
|
||||
const resolvedProjectDesignSystemId = resolveProjectDesignSystemId(currentProject);
|
||||
// A project can outlive a Design System being disabled in Settings. Keep the
|
||||
@@ -11455,11 +11456,11 @@ export function ProjectView({
|
||||
composerPlaceholder={
|
||||
projectCollab.materializationPending
|
||||
? t('designFiles.syncing')
|
||||
: projectMutationReadOnly
|
||||
? (projectCollab.ownerDisplayName
|
||||
? t('workspace.readonlyNoticeBy', { owner: projectCollab.ownerDisplayName })
|
||||
: t('workspace.readonlyNotice'))
|
||||
: undefined
|
||||
// Placeholder only EXPLAINS; `sendDisabled` above still keeps
|
||||
// the composer inert from the fail-closed flag. Undefined
|
||||
// here means "disabled, reason not yet known" -- the default
|
||||
// placeholder, not a claim about who owns this project.
|
||||
: readonlyNoticeText
|
||||
}
|
||||
queuedItems={currentConversationQueuedItems}
|
||||
error={conversationLoadError ?? error}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The copy a surface uses to EXPLAIN why a project is read-only.
|
||||
*
|
||||
* `undefined` means "read-only for a reason we cannot name yet". Surfaces must
|
||||
* still DISABLE in that window — callers keep deriving that from the
|
||||
* fail-closed flag — but they must not assert WHY, because the same flag is
|
||||
* true while ownership is merely unknown. Copy derived from it tells a
|
||||
* personal project's owner that their own project is someone else's share.
|
||||
*
|
||||
* Measured entering an owned personal project (OPEND-2283): the claim showed
|
||||
* for ~4.1s. Gating the file-workspace banner alone cut it to ~1.0s but did
|
||||
* not remove it, because the chat composer placeholder and the project title
|
||||
* tooltip each re-derived the claim from the flag. One named value for all
|
||||
* three is the point: a surface that wants the reason has to ask for the
|
||||
* reason, and there is exactly one answer.
|
||||
*/
|
||||
export function projectReadOnlyClaim(input: {
|
||||
/** Positive evidence of a different owner — never mere `!isOwner`. */
|
||||
isSharedNonOwner: boolean;
|
||||
ownerDisplayName?: string | null;
|
||||
t: (key: any, vars?: Record<string, string>) => string;
|
||||
}): string | undefined {
|
||||
if (!input.isSharedNonOwner) return undefined;
|
||||
return input.ownerDisplayName
|
||||
? input.t('workspace.readonlyNoticeBy', { owner: input.ownerDisplayName })
|
||||
: input.t('workspace.readonlyNotice');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { projectReadOnlyClaim } from '../../src/components/project-readonly-claim';
|
||||
|
||||
const t = (key: string, vars?: Record<string, string>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key;
|
||||
|
||||
describe('projectReadOnlyClaim', () => {
|
||||
it('names the owner once the share is confirmed', () => {
|
||||
expect(projectReadOnlyClaim({
|
||||
isSharedNonOwner: true,
|
||||
ownerDisplayName: '麻薯',
|
||||
t,
|
||||
})).toBe('workspace.readonlyNoticeBy:{"owner":"麻薯"}');
|
||||
});
|
||||
|
||||
it('falls back to the name-less notice when the share is confirmed but the owner is not', () => {
|
||||
expect(projectReadOnlyClaim({
|
||||
isSharedNonOwner: true,
|
||||
ownerDisplayName: null,
|
||||
t,
|
||||
})).toBe('workspace.readonlyNotice');
|
||||
});
|
||||
|
||||
// The whole point. A read-only surface may be read-only for reasons that are
|
||||
// not "someone shared this with you" — most commonly, ownership has simply
|
||||
// not resolved yet. Saying nothing is correct there; saying the shared-project
|
||||
// copy is a false claim about the viewer's own project.
|
||||
it('claims nothing while the share is unproven', () => {
|
||||
expect(projectReadOnlyClaim({
|
||||
isSharedNonOwner: false,
|
||||
ownerDisplayName: '麻薯',
|
||||
t,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user